feat: connect Subject Ticket assignments across public execution paths

This commit is contained in:
2026-10-30 00:11:02 +09:00
parent 7ad6030498
commit 6733aeef01
91 changed files with 8938 additions and 1731 deletions
+3
View File
@@ -8092,6 +8092,9 @@ pub struct TicketAssignmentSummary {
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))]
pub enum TicketAssignmentPrincipal {
Subject {
subject_id: String,
},
User {
account_id: String,
},
@@ -0,0 +1,45 @@
use server_api::{SetTicketRoleAssignmentRequest, TicketAssignmentPrincipal, TicketQueryRequest};
#[test]
fn subject_assignment_and_query_use_the_same_exact_principal_identity() {
for subject_id in ["subject-a", "subject-b"] {
let principal = serde_json::json!({ "kind": "subject", "subject_id": subject_id });
let assign: SetTicketRoleAssignmentRequest = serde_json::from_value(serde_json::json!({
"operation_id": "assign-subject", "expected_assignment_id": null, "principal": principal
}))
.unwrap();
assert!(
matches!(assign.principal, TicketAssignmentPrincipal::Subject { subject_id: ref id } if id == subject_id)
);
let query: TicketQueryRequest = serde_json::from_value(serde_json::json!({
"assigned_principal": principal
}))
.unwrap();
assert_eq!(
serde_json::to_value(query.assigned_principal).unwrap(),
principal
);
assert_eq!(serde_json::to_value(assign.principal).unwrap(), principal);
}
}
#[test]
fn assignment_principal_keeps_legacy_audit_and_rejects_subject_identity_spoofing() {
for principal in [
serde_json::json!({ "kind": "user", "account_id": "alice" }),
serde_json::json!({ "kind": "worker", "runtime_id": "runtime", "worker_id": "worker" }),
serde_json::json!({ "kind": "workspace_agent", "agent_key": "workspace-orchestrator" }),
] {
let retained: TicketAssignmentPrincipal =
serde_json::from_value(principal.clone()).unwrap();
assert_eq!(serde_json::to_value(retained).unwrap(), principal);
}
for malformed in [
serde_json::json!({ "kind": "subject" }),
serde_json::json!({ "kind": "subject", "subject_id": 1 }),
serde_json::json!({ "kind": "subject", "subject_id": "a", "worker_id": "old-body" }),
serde_json::json!({ "kind": "subject", "subject_id": "a", "agent_key": "workspace-orchestrator" }),
] {
assert!(serde_json::from_value::<TicketAssignmentPrincipal>(malformed).is_err());
}
}
+35
View File
@@ -49,3 +49,38 @@ record (`changes` includes `body`), never a later title-only edit. Missing or em
creator identities remain explicitly nullable, not inferred from later editors.
The retained base SQL and frozen fixture schemas remain unchanged; fresh creation
composes the ordered post migration.
## Subject responsibility schema
Component migration 14 owns the Subject principal CHECKs and assignment/current,
operation, and legacy tracking/history guards. Fresh standalone stores and Workspace
hosts use the same `migrate_sqlite_ticket_schema_in_transaction(&Connection)` startup
composition; the host owns its surrounding transaction, authentication, live Subject
body bindings, delivery/start fences, and notification routing. Request paths only
verify the owning schema and never install a host-dependent fixture or fallback.
Retained host startup migrations must instead call
`migrate_historical_sqlite_ticket_schema_in_transaction(&Connection, HistoricalTicketSchemaTarget)`
with the explicit `Cases10`, `Tracking11`, `Posts12`, or `Content13` boundary. This
bounded API shares the latest composer's migration engine; it cannot select arbitrary
versions or downgrade a current store. If the applied version is already newer, it
verifies that actual contract without replaying old steps or relabelling receipts.
Original baseline receipts are retained, including v6–9 baselines; their ordered
v7/10 composition receipts are appended instead of overwriting the baseline. Previously
retained normalized baseline10 histories remain supported. Neither composer is a
request-path API or a grant of legacy execution authority.
The additive migration leaves applied core/tracking SQL unchanged. It preflights
complete known table generations (standalone or Workspace-parent FKs), widens only
the three principal CHECKs with exact-SQL CAS, restores `writable_schema` on failure,
and checks integrity before accepting the migration. This avoids rebuilding immutable
parents and their host-owned FK graph inside a host transaction. Unsupported/mixed
tables and weakened guards fail closed; a trigger name alone never selects a schema
generation. Migration history selects the exact verified contract.
Old `workspace_agent` relationships, handling/results, audit source JSON, and receipts
remain readable as historical data, with a nonoperational diagnostic. They are not
converted to an arbitrary Subject or replayed as new input. Newly authorized Subject
Assign/reissue creates a distinct identity. Tracking sources use the Rust/JSON field
`subject_id`; historical `agent_key` audit JSON remains an unmodified JSON value,
not an alias accepted as current Subject authority.
+35 -10
View File
@@ -8,7 +8,7 @@
//! Close/reopen does not change handling; current responsibility can report after close
//! without restoring execution grants. Retained unknown handling is not inferred waiting/done.
//!
//! The host authenticates the current workspace-agent Worker and its actual Runtime
//! The host authenticates the current Subject body Worker and its actual Runtime
//! binding, including retries, inside the same write transaction as receipt comparison,
//! current-assignment/event CAS, audit, and result. Never split that transaction around
//! awaits. A replayed receipt is not a fresh event observation: reread before a new update,
@@ -48,7 +48,7 @@ pub struct TicketAssignmentTrackingUpdate {
pub tracking: TicketAssignmentTracking,
}
/// Backend-owned authenticated context, never decoded from model input. Agent
/// Backend-owned authenticated context, never decoded from model input. Subject
/// principal remains stable across Worker exchange; exact replay binds to the
/// authenticated concrete sender and its binding, not merely a claimed role.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -56,7 +56,7 @@ pub struct TicketAssignmentTrackingSourceContext {
pub actor: String,
pub runtime_id: String,
pub worker_id: String,
pub agent_key: String,
pub subject_id: String,
pub authentication_binding_id: String,
}
@@ -87,6 +87,9 @@ pub struct TicketAssignmentTrackingProjection {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TicketAssignmentPrincipalSnapshot {
Subject {
subject_id: String,
},
User {
account_id: String,
},
@@ -156,11 +159,11 @@ fn validate_update(
]
.into_iter()
.any(|value| !valid_identity(value))
|| source.agent_key != "workspace-orchestrator"
|| !valid_identity(&source.subject_id)
|| source.subject_id == "workspace-orchestrator"
{
return Err(TicketError::Conflict(
"tracking requires bounded identities and authenticated workspace-orchestrator source"
.into(),
"tracking requires bounded identities and authenticated Subject source".into(),
));
}
for text in [
@@ -216,7 +219,8 @@ pub fn sqlite_ticket_assignment_tracking(
.transpose()?;
// Legacy migration may retain current responsibility without evidence of
// handling status. Its real Assign audit is still an immutable observation
// against which the first explicit self-report can CAS, even after close.
// for a later explicitly authorized operation, even after close. This does
// not authorize a legacy agent relationship to report as a Subject.
// Never manufacture a tracking event/status or repair a missing audit. Once
// tracking exists, only its event identity is a valid observation.
let last_event_id = if current && tracking.is_none() && last_event_id.is_none() {
@@ -238,6 +242,9 @@ pub fn sqlite_ticket_assignment_tracking(
let missing =
|| TicketError::Conflict("stored assignment principal identity is incomplete".into());
let principal = match kind.as_str() {
"subject" => TicketAssignmentPrincipalSnapshot::Subject {
subject_id: principal_id.ok_or_else(missing)?,
},
"workspace_agent" => TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
agent_key: principal_id.ok_or_else(missing)?,
},
@@ -441,10 +448,10 @@ pub fn sqlite_update_ticket_assignment_tracking_in_transaction(
"tracking assignment is removed/replaced or last event changed".into(),
));
}
let principal_matches:bool=conn.query_row("SELECT principal_kind='workspace_agent' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.agent_key],|r|r.get(0)).map_err(sqlite_err)?;
let principal_matches:bool=conn.query_row("SELECT principal_kind='subject' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.subject_id],|r|r.get(0)).map_err(sqlite_err)?;
if !principal_matches {
return Err(TicketError::Conflict(
"authenticated agent does not own this assignment".into(),
"authenticated Subject does not own this assignment".into(),
));
}
let event_id=format!("tracking-{}",allocate_record_id(unix_epoch_millis_now().unwrap_or(0), |candidate| conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND event_id=?2)",params![workspace_id,format!("tracking-{candidate}")],|r|r.get::<_,bool>(0)).unwrap_or(true)).map_err(sqlite_err)?);
@@ -572,7 +579,10 @@ fn reconcile_legacy_tracking(conn: &Connection) -> Result<()> {
Ok(())
}
pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()> {
pub(crate) fn verify_assignment_tracking_schema(
conn: &Connection,
subject_contract: bool,
) -> Result<()> {
// Object names alone would accept same-named no-op triggers after drift.
let canonical = Connection::open_in_memory().map_err(sqlite_err)?;
canonical
@@ -603,6 +613,13 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
}
result.replace("ifnotexists", "")
};
// Generation is selected by the owning migration ledger, never by a trigger name.
if subject_contract {
canonical
.execute_batch(crate::subject_assignment::SUBJECT_GUARDS)
.map_err(sqlite_err)?;
crate::subject_assignment::verify(conn)?;
}
for name in [
"typed_ticket_assignment_tracking_history",
"typed_ticket_assignment_tracking_operations",
@@ -811,6 +828,14 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
|r| r.get(0),
)
.map_err(sqlite_err)?;
let expected_sql = if subject_contract {
expected_sql.replace(
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))",
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))",
)
} else {
expected_sql
};
let expected_sql = normalize(expected_sql);
let actual_sql = normalize(actual_sql);
let mut remainder = expected_sql.as_str();
+292 -17
View File
@@ -25,11 +25,20 @@ fn seed_ticket(conn: &Connection, workspace: &str, ticket: &str) {
}
fn assign(conn: &Connection, id: &str, event: &str) {
assign_principal(conn, id, event, "subject", "subject-a");
}
// Historical schema fixture only: never infer an authority fallback from DB shape.
fn assign_legacy(conn: &Connection, id: &str, event: &str) {
assign_principal(conn, id, event, "workspace_agent", "workspace-orchestrator");
}
fn assign_principal(conn: &Connection, id: &str, event: &str, kind: &str, principal: &str) {
let previous: Option<String> = conn.query_row("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[],|r|r.get(0)).optional().unwrap();
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','author','2026-10-10T00:00:00Z')",[id]).unwrap();
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator',?2,?3,'author','2026-10-10T00:00:00Z')",params![id,kind,principal]).unwrap();
conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[]).unwrap();
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','now')",[id]).unwrap();
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator',?2,?3,'now')",params![id,kind,principal]).unwrap();
conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,previous_assignment_id,actor,created_at,operation_id) VALUES ('w','t','orchestrator',?2,?4,?1,?3,'author','now',?2)",params![id,event,previous,if previous.is_some(){"reassigned"}else{"assigned"}]).unwrap();
conn.execute_batch("COMMIT").unwrap();
}
@@ -39,7 +48,7 @@ fn source() -> TicketAssignmentTrackingSourceContext {
actor: "orchestrator".into(),
runtime_id: "runtime".into(),
worker_id: "worker".into(),
agent_key: "workspace-orchestrator".into(),
subject_id: "subject-a".into(),
authentication_binding_id: "binding".into(),
}
}
@@ -92,7 +101,7 @@ fn assignment_initializes_waiting_and_audit_atomically() {
assert_eq!(history.len(), 1);
assert_eq!(history[0].event_id, "assigned-a");
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap();
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','subject','subject-a','author','now')",[]).unwrap();
conn.execute_batch("ROLLBACK").unwrap();
assert!(
sqlite_ticket_assignment_tracking(&conn, "w", "t", "rolled-back")
@@ -222,13 +231,13 @@ fn tracking_cas_and_receipts_bind_full_payload_and_authenticated_source() {
1 => sender.runtime_id = "other".into(),
2 => sender.worker_id = "replacement".into(),
3 => sender.authentication_binding_id = "replacement-binding".into(),
_ => sender.agent_key = "other-agent".into(),
_ => sender.subject_id = "subject-b".into(),
};
assert!(apply(&conn, &input, &sender).is_err());
}
let mut other = source();
other.worker_id = "replacement".into();
// Backend authenticated the replacement as the current stable agent. A new
// Backend authenticated the replacement as the current Subject body. A new
// operation can proceed on the same assignment without starting a new row.
let next = update(
"a",
@@ -323,7 +332,7 @@ fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> C
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-time');").unwrap();
install_assignment_core(&conn).unwrap();
seed_ticket(&conn, "w", "t");
assign(&conn, "a", "assigned-a");
assign_legacy(&conn, "a", "assigned-a");
let snapshot = TicketLegacyWorkflowSnapshot {
workflow_state: original.into(),
workflow_state_explicit: true,
@@ -347,7 +356,7 @@ fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> C
conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',0,'closed-later','case-fingerprint','{}')",[]).unwrap();
}
if reassigned {
assign(&conn, "b", "assigned-b");
assign_legacy(&conn, "b", "assigned-b");
}
conn
}
@@ -408,7 +417,7 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|r| r.get::<_, i64>(0)
)
.unwrap(),
13
14
);
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
@@ -459,8 +468,8 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
fn tracking_schema_rejects_drift_and_duplicate_current_singleton_roles() {
let conn = db();
assign(&conn, "a", "assigned-a");
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap();
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err());
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','subject','subject-a','author','now')",[]).unwrap();
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','subject','subject-a','now')",[]).is_err());
conn.execute_batch("DROP TRIGGER typed_ticket_assignment_tracking_history_no_update; CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update BEFORE UPDATE ON typed_ticket_assignment_tracking_history BEGIN SELECT 1; END;").unwrap();
assert!(
verify_sqlite_ticket_schema(&conn)
@@ -493,7 +502,7 @@ fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_
{
let conn = db();
assign(&conn, "a", "assigned-a");
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','original-time','original-fingerprint')",[]).unwrap();
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','subject','subject-a','a','original-time','original-fingerprint')",[]).unwrap();
for sql in [
"UPDATE ticket_worker_assignments SET assignment_id='changed' WHERE assignment_id='a'",
"UPDATE ticket_worker_assignments SET assigned_by='forged' WHERE assignment_id='a'",
@@ -518,7 +527,7 @@ fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_
// current row, even in a standalone Ticket store without host constraints.
conn.execute("DELETE FROM ticket_current_worker_assignments", [])
.unwrap();
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','workspace_agent','different-agent','now')",[]).is_err());
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','subject','subject-b','now')",[]).is_err());
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state) VALUES('w','pending','assign','t','worker','worker','runtime','pending-worker','claim-time','claim-fingerprint','pending')",[]).unwrap();
conn.execute("UPDATE ticket_assignment_operations SET worker_id='resolved-worker',binding_recovery_json='{}' WHERE operation_id='pending'",[]).unwrap();
conn.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='Admission failed' WHERE operation_id='pending'",[]).unwrap();
@@ -573,7 +582,7 @@ fn release_projection_preserves_actor_time_reason_and_optional_legacy_tracking()
TicketAssignmentTrackingStatus::Waiting
);
assert!(conn.execute("UPDATE ticket_worker_assignments SET tracking_json='{\"status\":\"done\"}' WHERE assignment_id='b'",[]).is_err());
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err());
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','subject','subject-a','now')",[]).is_err());
let legacy = legacy("done", true, false, false);
migrate_sqlite_ticket_schema(&legacy).unwrap();
legacy
@@ -694,15 +703,15 @@ fn standalone_assignment_constraints_reject_cross_ticket_principals_or_orphan_au
let conn = db();
assign(&conn, "a", "assigned-a");
for sql in [
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','workspace_agent','workspace-orchestrator','author','now')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','subject','subject-a','author','now')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-orchestrator','orchestrator','user','user','author','now')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','t','bad-owner','owner','worker','r','worker','author','now')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','workspace_agent','workspace-orchestrator','author','now')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','subject','subject-a','author','now')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,assigned_by,assigned_at) VALUES('w','t','missing-worker-identity','worker','worker','author','now')",
"INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('other','t','orchestrator','cross-audit','assigned','a','author','now')",
"INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('w','missing','orchestrator','cross-ticket-audit','assigned','a','author','now')",
"INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,created_at) VALUES('other','orphan-operation','assign','t','now')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','workspace_agent','workspace-orchestrator','author','now','{\"status\":\"running\"}')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','subject','subject-a','author','now','{\"status\":\"running\"}')",
] {
assert!(conn.execute(sql, []).is_err(), "reject {sql}");
}
@@ -850,3 +859,269 @@ fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_app
0
);
}
// Frozen component13 composition, not a latest-schema fixture relabelled as old.
fn legacy_v13() -> Connection {
let conn = legacy("no-snapshot", false, false, false);
migrate_assignment_tracking(&conn).unwrap();
conn.execute_batch(include_str!("post_migration.sql"))
.unwrap();
conn.execute_batch(include_str!("content_changes.sql"))
.unwrap();
conn.execute_batch(
"INSERT INTO ticket_schema_migrations VALUES
(11,'Ticket-owned assignment tracking','v11-time'),
(12,'Canonical Ticket posts','v12-time'),
(13,'Ticket content change facts','v13-time');",
)
.unwrap();
conn
}
#[test]
fn subject_migration_preserves_legacy_audit_and_receipts_without_inventing_subject_authority() {
let conn = legacy_v13();
let tracking = r#"{"status":"done","waiting_reason":null,"progress":null,"next_action":null,"result":"Historical result"}"#;
let old_source = r#"{"actor":"old-actor","runtime_id":"old-runtime","worker_id":"old-worker","agent_key":"workspace-orchestrator","authentication_binding_id":"old-binding"}"#;
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?1,last_tracking_event_id='old-event',tracking_updated_at='old-time' WHERE assignment_id='a'",[tracking]).unwrap();
conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','t','a','old-event','old-op',?1,?2,'old-time')",params![tracking,old_source]).unwrap();
let before = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
.unwrap();
let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap();
// Pre-Subject saved results remain byte-identical and are not new requests.
let mut old_input = update(
"a",
"assigned-a",
"old-op",
TicketAssignmentTrackingStatus::Done,
);
old_input.tracking = before.tracking.clone().unwrap();
conn.execute(
"INSERT INTO typed_ticket_assignment_tracking_operations VALUES('w','old-op','t',?1,?2,?3)",
params![
serde_json::to_string(&("t", &old_input)).unwrap(),
old_source,
serde_json::to_string(&before).unwrap()
],
)
.unwrap();
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','legacy-assign','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','old-time','old-fingerprint')",[]).unwrap();
let receipts = |db: &Connection| -> (String, String) {
db.query_row("SELECT source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE operation_key='old-op'",[],|r|Ok((r.get(0)?,r.get(1)?))).unwrap()
};
let saved_receipt = receipts(&conn);
migrate_sqlite_ticket_schema(&conn).unwrap();
let after = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
.unwrap();
assert_eq!(
after.principal,
TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
agent_key: "workspace-orchestrator".into()
}
);
assert_eq!(after.tracking, before.tracking);
assert_eq!(after.last_event_id, before.last_event_id);
assert_eq!(after.updated_at, before.updated_at);
assert!(after.current);
assert!(
after
.migration_diagnostic
.as_ref()
.unwrap()
.contains("nonoperational")
);
assert_eq!(
sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(),
history
);
assert_eq!(
history.last().unwrap().source_context["agent_key"],
"workspace-orchestrator"
);
assert_eq!(receipts(&conn), saved_receipt);
assert_eq!(conn.query_row("SELECT principal_kind FROM ticket_assignment_operations WHERE operation_id='legacy-assign'",[],|r|r.get::<_,String>(0)).unwrap(),"workspace_agent");
// Neither a random Subject nor spelling the old singleton as a Subject owns it.
for subject in ["subject-a", "workspace-orchestrator"] {
let mut sender = source();
sender.actor = "old-actor".into();
sender.runtime_id = "old-runtime".into();
sender.worker_id = "old-worker".into();
sender.authentication_binding_id = "old-binding".into();
sender.subject_id = subject.into();
assert!(
apply(
&conn,
&update(
"a",
"old-event",
"new-op",
TicketAssignmentTrackingStatus::Inprogress
),
&sender
)
.is_err()
);
// Exact historical input and concrete sender still cannot reinterpret
// old agent_key JSON as a new Subject-authenticated receipt.
assert!(apply(&conn, &old_input, &sender).is_err());
}
for sql in [
"UPDATE ticket_worker_assignments SET tracking_updated_at='new-time' WHERE assignment_id='a'",
"INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','t','a','new-event','new-op','{\"status\":\"done\"}','{}','now')",
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','legacy-new','orchestrator','workspace_agent','workspace-orchestrator','actor','now')",
] {
assert!(conn.execute(sql, []).is_err(), "reject {sql}");
}
migrate_sqlite_ticket_schema(&conn).unwrap();
assert_eq!(
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
.unwrap(),
after
);
// Explicit reissue is a new Subject identity, not a conversion of old audit.
assign(&conn, "subject-reissue", "subject-reissue-event");
apply(
&conn,
&update(
"subject-reissue",
"subject-reissue-event",
"subject-update",
TicketAssignmentTrackingStatus::Done,
),
&source(),
)
.unwrap();
assert_eq!(receipts(&conn), saved_receipt);
assert!(
!sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
.unwrap()
.current
);
let wire = serde_json::to_value(source()).unwrap();
assert_eq!(wire["subject_id"], "subject-a");
assert!(wire.get("agent_key").is_none());
// Old serialized principal snapshots and audit JSON are still readable as old types.
let old: TicketAssignmentTrackingProjection = serde_json::from_str(&saved_receipt.1).unwrap();
assert_eq!(old.principal, before.principal);
}
#[test]
fn subject_owner_is_required_for_new_operations_and_worker_replacement_is_not_reassignment() {
let conn = db();
assign(&conn, "a", "assigned-a");
let input = update(
"a",
"assigned-a",
"fresh",
TicketAssignmentTrackingStatus::Inprogress,
);
let mut wrong_subject = source();
wrong_subject.subject_id = "subject-b".into();
assert!(apply(&conn, &input, &wrong_subject).is_err());
let projection = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
.unwrap();
assert_eq!(
projection.principal,
TicketAssignmentPrincipalSnapshot::Subject {
subject_id: "subject-a".into()
}
);
let mut replacement = source();
replacement.worker_id = "new-body".into();
replacement.authentication_binding_id = "new-binding".into();
let result = apply(&conn, &input, &replacement).unwrap();
assert_eq!(result.assignment_id, "a");
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM typed_ticket_assignment_tracking_operations",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
}
#[test]
fn failed_subject_migration_rolls_back_schema_and_receipt_but_preserves_host_transaction() {
let conn = legacy_v13();
let original: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute_batch("CREATE TRIGGER host_reject_subject_migration BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments BEGIN SELECT RAISE(ABORT,'simulated failure after CHECK widening'); END;").unwrap();
conn.execute_batch("BEGIN IMMEDIATE; CREATE TABLE host_pending(value TEXT); INSERT INTO host_pending VALUES('keep');").unwrap();
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
assert!(!conn.is_autocommit());
assert_eq!(
conn.query_row("SELECT value FROM host_pending", [], |r| r
.get::<_, String>(0))
.unwrap(),
"keep"
);
assert_eq!(
conn.query_row(
"SELECT MAX(version) FROM ticket_schema_migrations",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
13
);
assert_eq!(
conn.query_row(
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
original
);
assert!(
!conn
.pragma_query_value(None, "writable_schema", |r| r.get::<_, bool>(0))
.unwrap()
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM sqlite_schema WHERE name='ticket_subject_current_insert'",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
0
);
conn.execute_batch("DROP TRIGGER host_reject_subject_migration;")
.unwrap();
migrate_sqlite_ticket_schema_in_transaction(&conn).unwrap();
conn.execute_batch("COMMIT").unwrap();
verify_sqlite_ticket_schema(&conn).unwrap();
}
#[test]
fn subject_generation_is_ledger_selected_and_never_repaired_by_a_marker_trigger() {
let conn = legacy_v13();
conn.execute_batch("CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
assert!(migrate_sqlite_ticket_schema(&conn).is_err());
assert_eq!(
conn.query_row(
"SELECT MAX(version) FROM ticket_schema_migrations",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
13
);
let conn = db();
conn.execute_batch("DROP TRIGGER ticket_subject_current_insert; CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
assert!(verify_sqlite_ticket_schema(&conn).is_err());
assert!(migrate_sqlite_ticket_schema(&conn).is_err());
}
+2
View File
@@ -31,6 +31,7 @@ mod assignment_tracking;
pub mod config;
mod content_changes;
mod sqlite_schema;
mod subject_assignment;
mod tags;
pub use assignment_tracking::*;
pub use tags::{
@@ -39,6 +40,7 @@ pub use tags::{
pub mod tool;
pub use sqlite_schema::{
HistoricalTicketSchemaTarget, migrate_historical_sqlite_ticket_schema_in_transaction,
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_in_transaction,
verify_legacy_sqlite_ticket_schema, verify_pre_classification_ticket_schema,
verify_sqlite_ticket_schema,
+162 -71
View File
@@ -20,7 +20,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 13;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 14;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
#[derive(Clone, Copy)]
@@ -320,11 +320,60 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
}
}
/// Appends assignment, post, and content-change fact migrations without replacing applied markers.
/// The host owns the surrounding transaction.
/// A savepoint rolls back this component on failure, without committing host work.
/// Current component 13 is verified only; historical edits are never backfilled.
/// Startup-only historical composition targets retained by Workspace migrations.
/// These are frozen component boundaries, not selectable request schemas or a
/// way to restore legacy execution authority. Current stores must use the latest
/// composer and request paths must use read-only verification instead.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HistoricalTicketSchemaTarget {
/// Two-state cases plus tags; no owning assignment tracking yet.
Cases10,
/// Owning assignment tracking, before canonical posts.
Tracking11,
/// Canonical posts, before content-change facts.
Posts12,
/// Content-change facts, before Subject responsibility constraints.
Content13,
}
impl HistoricalTicketSchemaTarget {
fn shape(self) -> TicketSchemaShape {
match self {
Self::Cases10 => TicketSchemaShape::Composed10,
Self::Tracking11 => TicketSchemaShape::Tracking11,
Self::Posts12 => TicketSchemaShape::Posts12,
Self::Content13 => TicketSchemaShape::ContentChanges13,
}
}
}
/// Compose one explicit retained host-migration boundary in the host's startup
/// transaction. This is NOT a request-path API or a downgrade/repair operation.
///
/// A recognized older preimage is verified before composition. Applied receipts
/// are retained; only missing migration receipts are appended. If the stored
/// version is already at or above the target, its ACTUAL version and complete
/// shape are verified without downgrading, relabelling, or replaying old steps.
/// Unknown history and weakened constraints fail closed. The component savepoint
/// rolls back failure without committing or discarding the caller's other work.
pub fn migrate_historical_sqlite_ticket_schema_in_transaction(
connection: &Connection,
target: HistoricalTicketSchemaTarget,
) -> Result<()> {
migrate_ticket_schema_to_in_transaction(connection, target.shape())
}
/// Appends owning component migrations through the latest contract without
/// replacing applied receipts. Startup only; the host owns the transaction.
/// A savepoint rolls back this component on failure without committing host work.
/// Already-current schemas are verified, never repaired or backfilled.
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
migrate_ticket_schema_to_in_transaction(connection, TicketSchemaShape::Subjects14)
}
fn migrate_ticket_schema_to_in_transaction(
connection: &Connection,
target: TicketSchemaShape,
) -> Result<()> {
if connection.is_autocommit() {
return Err(TicketError::Sqlite(
"Ticket component migration requires a host transaction".into(),
@@ -344,26 +393,36 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
let applied = load_applied_migrations(connection)?;
if applied.is_empty() {
create_latest_ticket_schema(connection)?;
connection
.execute_batch(include_str!("latest_schema.sql"))
.map_err(sqlite_err)?;
for version in 11..=target.version() {
apply_component_migration(connection, version)?;
}
connection
.execute(
"INSERT INTO ticket_schema_migrations VALUES (13, ?1, ?2)",
params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()],
"INSERT INTO ticket_schema_migrations VALUES (?1, ?2, ?3)",
params![
target.version(),
BASELINE_MIGRATION_NAME,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(sqlite_err)?;
} else {
let version = recognized_schema_version(&applied)?;
let mut version = recognized_schema_version(&applied)?;
if version == 6 {
// Retained v6 conversion establishes the verified v7 preimage.
// Preserve its original baseline receipt instead of renumbering it.
migrate_v6_to_v7(connection)?;
connection
.execute(
"UPDATE ticket_schema_migrations SET version = 7 WHERE version = 6",
[],
)
.map_err(sqlite_err)?;
append_component_receipt(connection, 7)?;
version = 7;
}
let shape = TicketSchemaShape::from_version(if version == 6 { 7 } else { version });
let shape = TicketSchemaShape::from_version(version);
verify_ticket_schema_shape(connection, shape)?;
if version >= target.version() {
return Ok(());
}
match shape {
TicketSchemaShape::Legacy7 => {
add_tag_schema(connection)?;
@@ -374,30 +433,19 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
TicketSchemaShape::Composed10
| TicketSchemaShape::Tracking11
| TicketSchemaShape::Posts12
| TicketSchemaShape::ContentChanges13 => {}
| TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14 => {}
}
if version < 10 {
connection
.execute(
"UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1",
params![chrono::Utc::now().to_rfc3339()],
)
.map_err(sqlite_err)?;
append_component_receipt(connection, 10)?;
version = 10;
}
if version < 11 {
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
connection.execute("INSERT INTO ticket_schema_migrations VALUES (11, 'Ticket-owned assignment tracking', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
}
if version < 12 {
migrate_posts(connection)?;
connection.execute("INSERT INTO ticket_schema_migrations VALUES (12, 'Canonical Ticket posts', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
}
if version < 13 {
migrate_content_changes(connection)?;
connection.execute("INSERT INTO ticket_schema_migrations VALUES (13, 'Ticket content change facts', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
for next in version + 1..=target.version() {
apply_component_migration(connection, next)?;
append_component_receipt(connection, next)?;
}
}
verify_sqlite_ticket_schema(connection)
verify_ticket_schema_shape(connection, target)
})();
match result {
Ok(()) => connection
@@ -421,6 +469,7 @@ enum TicketSchemaShape {
Tracking11,
Posts12,
ContentChanges13,
Subjects14,
}
impl TicketSchemaShape {
fn from_version(version: i64) -> Self {
@@ -432,6 +481,7 @@ impl TicketSchemaShape {
11 => Self::Tracking11,
12 => Self::Posts12,
13 => Self::ContentChanges13,
14 => Self::Subjects14,
_ => unreachable!(),
}
}
@@ -444,6 +494,7 @@ impl TicketSchemaShape {
Self::Tracking11 => 11,
Self::Posts12 => 12,
Self::ContentChanges13 => 13,
Self::Subjects14 => 14,
}
}
fn tags(self) -> bool {
@@ -454,6 +505,7 @@ impl TicketSchemaShape {
| Self::Tracking11
| Self::Posts12
| Self::ContentChanges13
| Self::Subjects14
)
}
fn cases(self) -> bool {
@@ -464,13 +516,14 @@ impl TicketSchemaShape {
| Self::Tracking11
| Self::Posts12
| Self::ContentChanges13
| Self::Subjects14
)
}
}
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
verify_ticket_schema_shape(connection, TicketSchemaShape::ContentChanges13)
verify_ticket_schema_shape(connection, TicketSchemaShape::Subjects14)
}
/// Retained Workspace schema-66 migration produces the pre-classification v7
@@ -484,7 +537,7 @@ pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Resul
/// treating historical workflow values as the current two-state case authority.
pub fn verify_legacy_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
let applied = load_applied_migrations(connection)?;
let version = applied.keys().next().copied().unwrap_or_default();
let version = recognized_schema_version(&applied)?;
if !matches!(version, 7 | 8) {
return Err(TicketError::Sqlite(
"legacy Ticket schema requires component 7 or 8".into(),
@@ -586,7 +639,9 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
let mut columns = columns.to_vec();
if matches!(
shape,
TicketSchemaShape::Posts12 | TicketSchemaShape::ContentChanges13
TicketSchemaShape::Posts12
| TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14
) {
if table == "typed_tickets" {
columns.retain(|c| c.name != "body");
@@ -831,16 +886,20 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
TicketSchemaShape::Tracking11
| TicketSchemaShape::Posts12
| TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14
) {
if let Err(error) =
crate::assignment_tracking::verify_assignment_tracking_schema(connection)
{
if let Err(error) = crate::assignment_tracking::verify_assignment_tracking_schema(
connection,
matches!(shape, TicketSchemaShape::Subjects14),
) {
push_diagnostic(&mut diagnostics, error.to_string());
}
}
if matches!(
shape,
TicketSchemaShape::Posts12 | TicketSchemaShape::ContentChanges13
TicketSchemaShape::Posts12
| TicketSchemaShape::ContentChanges13
| TicketSchemaShape::Subjects14
) {
collect_index_diagnostics(
connection,
@@ -851,7 +910,10 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
&mut diagnostics,
);
}
if matches!(shape, TicketSchemaShape::ContentChanges13) {
if matches!(
shape,
TicketSchemaShape::ContentChanges13 | TicketSchemaShape::Subjects14
) {
collect_content_change_diagnostics(connection, &mut diagnostics)?;
}
if diagnostics.is_empty() {
@@ -870,13 +932,41 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
}
}
fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
// Both fresh and retained composition use this single ordered step dispatcher.
fn apply_component_migration(connection: &Connection, version: i64) -> Result<()> {
match version {
11 => crate::assignment_tracking::migrate_assignment_tracking(connection),
12 => migrate_posts(connection),
13 => migrate_content_changes(connection),
14 => crate::subject_assignment::migrate(connection),
_ => unreachable!("bounded component migration"),
}
}
fn component_migration_name(version: i64) -> &'static str {
match version {
7 => "Ordered Ticket targets",
10 => "Ticket case and tag composition",
11 => "Ticket-owned assignment tracking",
12 => "Canonical Ticket posts",
13 => "Ticket content change facts",
14 => "Subject Ticket responsibility",
_ => unreachable!("bounded component receipt"),
}
}
fn append_component_receipt(connection: &Connection, version: i64) -> Result<()> {
connection
.execute_batch(include_str!("latest_schema.sql"))
.execute(
"INSERT INTO ticket_schema_migrations VALUES (?1, ?2, ?3)",
params![
version,
component_migration_name(version),
chrono::Utc::now().to_rfc3339()
],
)
.map_err(sqlite_err)?;
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
migrate_posts(connection)?;
migrate_content_changes(connection)
Ok(())
}
fn migrate_content_changes(connection: &Connection) -> Result<()> {
@@ -1212,26 +1302,24 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
}
fn recognized_schema_version(applied: &BTreeMap<i64, String>) -> Result<i64> {
if applied.len() == 1 {
if let Some((&version, name)) = applied.first_key_value() {
if (6..=13).contains(&version) && name == BASELINE_MIGRATION_NAME {
return Ok(version);
}
}
}
for baseline in [10, 11, 12] {
let mut expected = BTreeMap::from([(baseline, BASELINE_MIGRATION_NAME.to_owned())]);
for (version, name) in [
(11, "Ticket-owned assignment tracking"),
(12, "Canonical Ticket posts"),
(13, "Ticket content change facts"),
] {
if version <= baseline {
continue;
}
expected.insert(version, name.to_owned());
if let Some((&baseline, name)) = applied.first_key_value() {
if (6..=LATEST_SQLITE_TICKET_SCHEMA_VERSION).contains(&baseline)
&& name == BASELINE_MIGRATION_NAME
{
let mut expected = BTreeMap::from([(baseline, name.clone())]);
if applied == &expected {
return Ok(version);
return Ok(baseline);
}
// Versions 8 and 9 were independent baseline generations, not
// ordered upgrades. Both compose at10; v6 additionally needs v7.
for version in [7, 10, 11, 12, 13, 14] {
if version <= baseline || (version == 7 && baseline != 6) {
continue;
}
expected.insert(version, component_migration_name(version).to_owned());
if applied == &expected {
return Ok(version);
}
}
}
}
@@ -1733,10 +1821,13 @@ mod tests {
assert_eq!(
load_applied_migrations(&connection).unwrap(),
BTreeMap::from([
(10, BASELINE_MIGRATION_NAME.to_owned()),
(6, BASELINE_MIGRATION_NAME.to_owned()),
(7, "Ordered Ticket targets".to_owned()),
(10, "Ticket case and tag composition".to_owned()),
(11, "Ticket-owned assignment tracking".to_owned()),
(12, "Canonical Ticket posts".to_owned()),
(13, "Ticket content change facts".to_owned())
(13, "Ticket content change facts".to_owned()),
(14, "Subject Ticket responsibility".to_owned())
])
);
}
@@ -1879,7 +1970,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain a canonical baseline and ordered migrations through 13"
"migration history must contain a canonical baseline and ordered migrations through 14"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
@@ -1901,7 +1992,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain a canonical baseline and ordered migrations through 13"
"migration history must contain a canonical baseline and ordered migrations through 14"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
+294
View File
@@ -0,0 +1,294 @@
//! Component 14 owns Subject assignment table constraints and guards. It never
//! maps a legacy agent to a Subject, starts work, or rewrites historical receipts.
//! The host separately owns authenticated live Subject bindings, delivery and startup fences.
use rusqlite::{Connection, OptionalExtension, params};
const PRINCIPAL_TABLES: [&str; 3] = [
"ticket_assignment_operations",
"ticket_current_worker_assignments",
"ticket_worker_assignments",
];
const OLD_KINDS: &str = "CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))";
const SUBJECT_KINDS: &str =
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))";
pub(crate) const SUBJECT_GUARDS: &str = include_str!("subject_assignment_schema.sql");
const LEGACY_DIAGNOSTIC: &str = "Legacy Orchestrator identity retained without a trustworthy historical Subject mapping; nonoperational, explicit authorized Subject assignment required. Saved history and receipts do not grant execution authority.";
#[derive(Debug, thiserror::Error)]
enum MigrationError {
#[error("{0}")]
Sqlite(#[from] rusqlite::Error),
#[error("{0}")]
Contract(String),
}
type Result<T> = std::result::Result<T, MigrationError>;
/// Called only by the owning startup migration, within its savepoint. The caller
/// rolls back on error. No independent ledger, transaction, or authority fallback.
pub(crate) fn migrate(conn: &Connection) -> crate::Result<()> {
let result = (|| -> Result<()> {
if conn.is_autocommit() {
return Err(MigrationError::Contract(
"Subject assignment migration requires a caller-owned transaction".into(),
));
}
widen_principal_checks(conn)?;
conn.execute_batch(SUBJECT_GUARDS)?;
conn.execute(
"UPDATE ticket_worker_assignments
SET tracking_migration_diagnostic = CASE
WHEN tracking_migration_diagnostic IS NULL OR tracking_migration_diagnostic=''
THEN ?1 ELSE tracking_migration_diagnostic || char(10) || ?1 END
WHERE role='orchestrator' AND principal_kind!='subject'",
[LEGACY_DIAGNOSTIC],
)?;
verify_inner(conn)?;
verify_integrity(conn)
})();
result.map_err(super::sqlite_err)
}
pub(crate) fn verify(conn: &Connection) -> crate::Result<()> {
verify_inner(conn).map_err(super::sqlite_err)
}
fn verify_inner(conn: &Connection) -> Result<()> {
require_schema_writes_closed(conn)?;
let canonical = known_table_shape(conn, true)?;
canonical.execute_batch(SUBJECT_GUARDS)?;
let mut statement =
canonical.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' ORDER BY name")?;
for name in statement.query_map([], |row| row.get::<_, String>(0))? {
require_same_sql(conn, &canonical, "trigger", &name?)?;
}
Ok(())
}
fn require_schema_writes_closed(conn: &Connection) -> Result<()> {
for setting in ["writable_schema", "ignore_check_constraints"] {
if conn.pragma_query_value(None, setting, |row| row.get::<_, bool>(0))? {
return Err(MigrationError::Contract(format!(
"Subject assignment schema requires {setting} OFF"
)));
}
}
Ok(())
}
fn schema_sql(conn: &Connection, kind: &str, name: &str) -> Result<String> {
conn.query_row(
"SELECT sql FROM main.sqlite_schema WHERE type=?1 AND name=?2",
params![kind, name],
|row| row.get(0),
)
.optional()?
.ok_or_else(|| MigrationError::Contract(format!("Missing Subject assignment {kind} {name}")))
}
fn require_same_sql(
conn: &Connection,
canonical: &Connection,
kind: &str,
name: &str,
) -> Result<()> {
if normalize_sql(&schema_sql(conn, kind, name)?)
!= normalize_sql(&schema_sql(canonical, kind, name)?)
{
return Err(MigrationError::Contract(format!(
"Subject assignment {kind} {name} differs from canonical schema"
)));
}
Ok(())
}
/// Token boundaries and quoted literal bytes are significant. Only whitespace,
/// unquoted ASCII case and SQLite's ALTER-generated quoting of these three known
/// table identifiers are normalized. In particular, do not globally strip quotes,
/// comments, IF NOT EXISTS, whitespace in literals, or merge adjacent SQL tokens.
fn normalize_sql(sql: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut chars = sql.chars().peekable();
while let Some(ch) = chars.next() {
if ch.is_whitespace() {
continue;
}
if matches!(ch, '\'' | '"' | '`' | '[') {
let end = if ch == '[' { ']' } else { ch };
let mut token = String::from(ch);
while let Some(next) = chars.next() {
token.push(next);
if next == end {
if chars.peek() == Some(&end) && end != ']' {
token.push(chars.next().expect("peeked quote"));
} else {
break;
}
}
}
if ch == '"'
&& PRINCIPAL_TABLES
.iter()
.any(|table| token == format!("\"{table}\""))
{
token = token[1..token.len() - 1].to_owned();
}
tokens.push(token);
} else if ch.is_alphanumeric() || matches!(ch, '_' | '$') {
let mut token = ch.to_string();
while chars
.peek()
.is_some_and(|next| next.is_alphanumeric() || matches!(*next, '_' | '$'))
{
token.push(chars.next().expect("peeked token"));
}
tokens.push(token.to_ascii_lowercase());
} else {
tokens.push(ch.to_string());
}
}
tokens
}
/// Reconstruct both explicitly supported historical generations from the immutable
/// Ticket-owned core and tracking DDL. Only Workspace-parent FK presence differs.
fn canonical_tables(subject: bool, workspace_parent: bool) -> Result<Connection> {
let canonical = Connection::open_in_memory()?;
let historical = super::TICKET_ASSIGNMENT_SCHEMA_SQL;
let mut sql = if workspace_parent {
historical.to_owned()
} else {
historical.replace("FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,", "")
.replace(",\n FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE\n", "\n")
};
if subject {
sql = sql.replace(OLD_KINDS, SUBJECT_KINDS);
}
canonical.execute_batch(&sql)?;
canonical.execute_batch(include_str!("assignment_tracking_schema.sql"))?;
Ok(canonical)
}
/// Match a whole published three-table generation. The operation table selects
/// the known Workspace-FK variant; the other two must match that SAME variant.
/// Mixed principal generations or one-off dropped constraints are not accepted.
fn known_table_shape(conn: &Connection, subject: bool) -> Result<Connection> {
let operation = normalize_sql(&schema_sql(conn, "table", PRINCIPAL_TABLES[0])?);
for workspace_parent in [true, false] {
let canonical = canonical_tables(subject, workspace_parent)?;
if operation != normalize_sql(&schema_sql(&canonical, "table", PRINCIPAL_TABLES[0])?) {
continue;
}
for table in PRINCIPAL_TABLES {
if normalize_sql(&schema_sql(conn, "table", table)?)
!= normalize_sql(&schema_sql(&canonical, "table", table)?)
{
return Err(MigrationError::Contract(format!(
"Unsupported complete assignment table SQL in {table}; refusing unknown or mixed schema"
)));
}
}
return Ok(canonical);
}
Err(MigrationError::Contract(format!(
"Unsupported complete assignment table SQL in {}; refusing unknown schema",
PRINCIPAL_TABLES[0]
)))
}
/// SQLite's documented CHECK-only schema-change procedure avoids rebuilding
/// immutable assignment parents and their many FK dependents. No columns,
/// rootpages, rowids, index definitions, or data bytes change. The *entire* known
/// old/new table SQL is preflighted before schema writes. The UPDATE targets are
/// exactly the three named main-schema table rows and use exact-SQL CAS.
///
/// A normal DROP/rebuild needs foreign_keys=OFF *before* the caller transaction:
/// defer_foreign_keys does not defer CASCADE actions or immutable child-delete
/// triggers. Renaming with FK enforcement rewrites inbound references. Rebuilding
/// the complete FK graph would include immutable tracking, receipts, work releases,
/// origins, deliveries, startup fences and historical implementation delegations.
/// That would require a different migration boundary and much broader data churn.
/// For this CHECK-only change under the agreed caller transaction, the documented
/// bounded schema edit is safer than an improvised graph rebuild. If table layout
/// ever changes, this helper must reject it; it is not a general migration engine.
fn widen_principal_checks(conn: &Connection) -> Result<()> {
require_schema_writes_closed(conn)?;
let old = known_table_shape(conn, false)?;
let mut changes = Vec::new();
for table in PRINCIPAL_TABLES {
let sql = schema_sql(conn, "table", table)?;
if sql.matches(OLD_KINDS).count() != 1 {
return Err(MigrationError::Contract(format!(
"Unsupported principal CHECK spelling in {table}"
)));
}
changes.push((table, sql.replace(OLD_KINDS, SUBJECT_KINDS), sql));
}
// Do not silently repair same-named weakened principal triggers on upgrade.
for name in [
"typed_ticket_assignment_orchestrator_principal_insert",
"typed_ticket_assignment_role_principal_insert",
] {
require_same_sql(conn, &old, "trigger", name)?;
}
let cookie: i64 = conn.pragma_query_value(None, "schema_version", |row| row.get(0))?;
if !(0..i64::from(i32::MAX)).contains(&cookie) {
return Err(MigrationError::Contract(
"SQLite schema cookie cannot be advanced".into(),
));
}
conn.pragma_update(None, "writable_schema", true)?;
let change_result = (|| {
let enabled: bool = conn.pragma_query_value(None, "writable_schema", |row| row.get(0))?;
if !enabled {
return Err(MigrationError::Contract(
"SQLite refused the CHECK-only Subject schema migration".into(),
));
}
for (table, new_sql, old_sql) in changes {
let changed = conn.execute(
"UPDATE main.sqlite_schema SET sql=?2 WHERE type='table' AND name=?1 AND tbl_name=?1 AND sql=?3",
params![table, new_sql, old_sql],
)?;
if changed != 1 {
return Err(MigrationError::Contract(format!(
"Principal CHECK changed concurrently in {table}"
)));
}
}
// Engine schema cache generation, NOT the application migration ledger.
conn.pragma_update(None, "schema_version", cookie + 1)?;
Ok(())
})();
// writable_schema is connection state, not transactional: restore it even
// on failure, before returning control to the caller's rollback path.
let restore_result = conn.pragma_update(None, "writable_schema", false);
restore_result?;
require_schema_writes_closed(conn)?;
change_result?;
conn.flush_prepared_statement_cache();
// Force a complete reparse with writable_schema OFF. Malformed schema must
// fail here rather than leave an apparently successful migration receipt.
for table in PRINCIPAL_TABLES {
conn.prepare(&format!("SELECT * FROM {table} LIMIT 0"))?;
}
Ok(())
}
fn verify_integrity(conn: &Connection) -> Result<()> {
let integrity: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
if integrity != "ok" {
return Err(MigrationError::Contract(format!(
"Subject assignment migration integrity check failed: {integrity}"
)));
}
let violations: i64 =
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})?;
if violations != 0 {
return Err(MigrationError::Contract(
"Subject assignment migration foreign-key violation".into(),
));
}
Ok(())
}
@@ -0,0 +1,54 @@
-- Component 14: additive Subject responsibility guards. Historical identities remain readable.
DROP TRIGGER typed_ticket_assignment_orchestrator_principal_insert;
CREATE TRIGGER typed_ticket_assignment_orchestrator_principal_insert
BEFORE INSERT ON ticket_worker_assignments
WHEN NEW.role='orchestrator' AND NEW.principal_kind!='subject'
BEGIN SELECT RAISE(ABORT,'New Orchestrator responsibility requires a Subject principal'); END;
DROP TRIGGER typed_ticket_assignment_role_principal_insert;
CREATE TRIGGER typed_ticket_assignment_role_principal_insert
BEFORE INSERT ON ticket_worker_assignments
WHEN (NEW.role='owner' AND NEW.principal_kind!='user')
OR (NEW.role='worker' AND NEW.principal_kind IN ('workspace_agent','subject'))
OR (NEW.principal_kind='workspace_agent' AND NEW.principal_id!='workspace-orchestrator')
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
BEGIN SELECT RAISE(ABORT,'Assignment role and principal are incompatible'); END;
CREATE TRIGGER ticket_subject_current_insert
BEFORE INSERT ON ticket_current_worker_assignments
WHEN (NEW.role='orchestrator' AND NEW.principal_kind!='subject')
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
BEGIN SELECT RAISE(ABORT,'Current Orchestrator responsibility requires a Subject principal'); END;
CREATE TRIGGER ticket_subject_current_update
BEFORE UPDATE OF workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,runtime_id,worker_id
ON ticket_current_worker_assignments
WHEN (NEW.role='orchestrator' AND NEW.principal_kind!='subject')
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
BEGIN SELECT RAISE(ABORT,'Current Orchestrator responsibility requires a Subject principal'); END;
CREATE TRIGGER ticket_subject_operation_insert
BEFORE INSERT ON ticket_assignment_operations
WHEN (NEW.role='orchestrator' AND NEW.action!='unassign' AND NEW.principal_kind!='subject')
OR (NEW.principal_kind='subject' AND (NEW.role!='orchestrator'
OR NEW.principal_id IS NULL OR length(trim(NEW.principal_id))=0
OR NEW.runtime_id IS NOT NULL OR NEW.worker_id IS NOT NULL))
BEGIN SELECT RAISE(ABORT,'New Orchestrator assignment operation requires a Subject principal'); END;
CREATE TRIGGER ticket_subject_operation_update
BEFORE UPDATE ON ticket_assignment_operations
WHEN (NEW.role='orchestrator' AND NEW.action!='unassign' AND NEW.principal_kind!='subject')
OR (NEW.principal_kind='subject' AND (NEW.role!='orchestrator'
OR NEW.principal_id IS NULL OR length(trim(NEW.principal_id))=0
OR NEW.runtime_id IS NOT NULL OR NEW.worker_id IS NOT NULL))
BEGIN SELECT RAISE(ABORT,'New Orchestrator assignment operation requires a Subject principal'); END;
CREATE TRIGGER ticket_legacy_orchestrator_tracking_no_update
BEFORE UPDATE OF tracking_json,last_tracking_event_id,tracking_updated_at ON ticket_worker_assignments
WHEN OLD.role='orchestrator' AND OLD.principal_kind!='subject'
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
CREATE TRIGGER ticket_legacy_orchestrator_history_no_insert
BEFORE INSERT ON typed_ticket_assignment_tracking_history
WHEN EXISTS(SELECT 1 FROM ticket_worker_assignments a
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.assignment_id
AND a.role='orchestrator' AND a.principal_kind!='subject')
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
+227 -2
View File
@@ -78,7 +78,17 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
row.get(0)
})
.unwrap();
assert_eq!(marker, 10);
assert_eq!(marker, 7);
assert_eq!(
connection
.query_row(
"SELECT applied_at FROM ticket_schema_migrations WHERE version=7",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
"legacy"
);
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
assert_eq!(after_events, historical_events);
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
@@ -442,7 +452,7 @@ fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receip
verify_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(
rows(&connection, "ticket_schema_migrations")[0][0],
rusqlite::types::Value::Integer(10)
rusqlite::types::Value::Integer(8)
);
for (table, expected) in preserved.iter().zip(&before) {
assert_eq!(&rows(&connection, table), expected, "changed {table}");
@@ -852,3 +862,218 @@ fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket
.unwrap();
assert_eq!(failures, 0);
}
fn compose_historical(connection: &Connection, target: HistoricalTicketSchemaTarget) {
connection.execute_batch("BEGIN IMMEDIATE").unwrap();
migrate_historical_sqlite_ticket_schema_in_transaction(connection, target).unwrap();
assert!(!connection.is_autocommit());
connection.execute_batch("COMMIT").unwrap();
}
fn max_component(connection: &Connection) -> i64 {
connection
.query_row(
"SELECT MAX(version) FROM ticket_schema_migrations",
[],
|r| r.get(0),
)
.unwrap()
}
#[test]
fn historical_composition_stops_at_each_explicit_boundary_and_preserves_applied_receipts() {
use HistoricalTicketSchemaTarget::{Cases10, Content13, Posts12, Tracking11};
// Both empty stores and real old schema7 preimages must stop at the chosen
// contract, rather than installing the latest Subject schema prematurely.
for retained in [false, true] {
for (target, version) in [
(Cases10, 10),
(Tracking11, 11),
(Posts12, 12),
(Content13, 13),
] {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
if retained {
schema7(&conn);
}
assert!(migrate_historical_sqlite_ticket_schema_in_transaction(&conn, target).is_err());
compose_historical(&conn, target);
assert_eq!(max_component(&conn), version);
let exists = |kind: &str, name: &str| -> bool {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)",
params![kind, name],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(
exists("table", "typed_ticket_assignment_tracking_history"),
version >= 11
);
assert_eq!(exists("index", "typed_ticket_events_ref"), version >= 12);
assert_eq!(
exists("table", "typed_ticket_content_changes"),
version >= 13
);
assert!(!exists("trigger", "ticket_subject_current_insert"));
if retained {
assert_eq!(
conn.query_row(
"SELECT name,applied_at FROM ticket_schema_migrations WHERE version=7",
[],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
)
.unwrap(),
("ticket schema baseline".into(), "legacy".into())
);
}
let receipts = rows(&conn, "ticket_schema_migrations");
compose_historical(&conn, target);
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
// Older host hooks verify the actual newer historical generation;
// they neither replay older steps nor change its receipts.
compose_historical(&conn, Cases10);
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
assert!(verify_sqlite_ticket_schema(&conn).is_err());
}
}
let conn = Connection::open_in_memory().unwrap();
schema7(&conn);
let mut prior = rows(&conn, "ticket_schema_migrations");
for target in [Cases10, Tracking11, Posts12, Content13] {
compose_historical(&conn, target);
let after = rows(&conn, "ticket_schema_migrations");
assert_eq!(&after[..prior.len()], prior.as_slice());
prior = after;
}
}
#[test]
fn historical_composition_verifies_actual_current_contract_without_downgrade_or_repair() {
let conn = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
let receipts = rows(&conn, "ticket_schema_migrations");
for target in [
HistoricalTicketSchemaTarget::Cases10,
HistoricalTicketSchemaTarget::Tracking11,
HistoricalTicketSchemaTarget::Posts12,
HistoricalTicketSchemaTarget::Content13,
] {
compose_historical(&conn, target);
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
verify_sqlite_ticket_schema(&conn).unwrap();
}
// A lower requested target cannot hide a weakened actual14 guard.
conn.execute_batch("DROP TRIGGER ticket_subject_current_insert; CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
assert!(
migrate_historical_sqlite_ticket_schema_in_transaction(
&conn,
HistoricalTicketSchemaTarget::Cases10
)
.is_err()
);
assert!(!conn.is_autocommit());
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
conn.execute_batch("ROLLBACK").unwrap();
// A claimed13 marker over actual10 must also be rejected, not backfilled.
let conn = Connection::open_in_memory().unwrap();
compose_historical(&conn, HistoricalTicketSchemaTarget::Cases10);
conn.execute("UPDATE ticket_schema_migrations SET version=13", [])
.unwrap();
let before = rows(&conn, "ticket_schema_migrations");
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
assert!(
migrate_historical_sqlite_ticket_schema_in_transaction(
&conn,
HistoricalTicketSchemaTarget::Cases10
)
.is_err()
);
assert_eq!(rows(&conn, "ticket_schema_migrations"), before);
conn.execute_batch("ROLLBACK").unwrap();
}
#[test]
fn bounded_content13_reopens_after_failed_subject_upgrade_then_migrates_atomically_to14() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("retained-content13.db");
let (receipts, history, old_table);
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
// Actual retained13 accepts its historical stable-agent contract. It
// has not been created at14 and then merely relabelled as old.
conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('workspace','t','t','Title','open','task','P2','open',1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('workspace','t',-1,'t:-1','comment','Body');
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('workspace','t','old','orchestrator','workspace_agent','workspace-orchestrator','old-author','old-time');
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('workspace','t','old','orchestrator','workspace_agent','workspace-orchestrator','old-time');
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id) VALUES('workspace','t','orchestrator','old-assigned','assigned','old','old-author','old-time','old-op');
CREATE TRIGGER host_reject_subject_migration BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments BEGIN SELECT RAISE(ABORT,'forced post-widen failure'); END;").unwrap();
receipts = rows(&conn, "ticket_schema_migrations");
history = rows(&conn, "typed_ticket_assignment_tracking_history");
old_table = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
[],
|r| r.get::<_, String>(0),
)
.unwrap();
conn.execute_batch("BEGIN IMMEDIATE; CREATE TABLE host_work(value TEXT); INSERT INTO host_work VALUES('keep');").unwrap();
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
assert!(!conn.is_autocommit());
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
assert_eq!(
rows(&conn, "typed_ticket_assignment_tracking_history"),
history
);
assert_eq!(
conn.query_row(
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
old_table
);
assert!(
!conn
.pragma_query_value(None, "writable_schema", |r| r.get::<_, bool>(0))
.unwrap()
);
conn.execute_batch("COMMIT").unwrap();
}
let conn = Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
assert_eq!(max_component(&conn), 13);
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
assert_eq!(
conn.query_row("SELECT value FROM host_work", [], |r| r.get::<_, String>(0))
.unwrap(),
"keep"
);
conn.execute_batch("DROP TRIGGER host_reject_subject_migration")
.unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
verify_sqlite_ticket_schema(&conn).unwrap();
assert_eq!(max_component(&conn), 14);
assert_eq!(
&rows(&conn, "ticket_schema_migrations")[..receipts.len()],
receipts.as_slice()
);
assert_eq!(
rows(&conn, "typed_ticket_assignment_tracking_history"),
history
);
drop(conn);
let conn = Connection::open(&path).unwrap();
let applied14 = rows(&conn, "ticket_schema_migrations");
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
assert_eq!(rows(&conn, "ticket_schema_migrations"), applied14);
verify_sqlite_ticket_schema(&conn).unwrap();
}
+1 -1
View File
@@ -270,7 +270,7 @@ fn schema12_migrates_body_authors_archived_history_and_stable_references_then_re
|r| r.get::<_, i64>(0)
)
.unwrap(),
13
14
);
backend
.add_event("known".into(), comment("Next", "author"))
+2 -2
View File
@@ -597,9 +597,9 @@ fn verifier_rejects_weakened_fact_constraints_trigger_and_migration_history() {
assert!(migrate_sqlite_ticket_schema(&f.conn).is_err(), "{from}");
}
for mutation in [
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=13",
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=14",
"INSERT INTO ticket_schema_migrations VALUES(12,'Canonical Ticket posts','invalid extra history')",
"UPDATE ticket_schema_migrations SET version=14 WHERE version=13",
"UPDATE ticket_schema_migrations SET version=15 WHERE version=14",
] {
let f = Fixture::new();
f.conn.execute_batch(mutation).unwrap();
@@ -21,7 +21,7 @@ use crate::feature::{
const FEATURE_ID: &str = "orchestration";
const TOOL_NAME: &str = "SpawnTicketWorker";
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Assigned Subject starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
#[derive(Debug, Default)]
pub struct OrchestrationFeature;
@@ -84,7 +84,7 @@ struct SpawnTicketWorkerWorkdirInput {
#[serde(deny_unknown_fields)]
struct SpawnTicketWorkerInput {
ticket_id: String,
/// Observed current delegation identity. Required by Backend for an Orchestrator start; manual callers omit it.
/// Observed current delegation identity. Required by Backend for an assigned Subject start; manual callers omit it.
#[serde(default)]
orchestrator_assignment_id: Option<String>,
runtime_id: String,
@@ -103,6 +103,10 @@ struct SpawnTicketWorkerInput {
/// Workdirs. Backend derives effective capabilities; callers cannot request them.
#[serde(default)]
workdir_attachments: Vec<SpawnTicketWorkerWorkdirInput>,
/// Existing general delegation contract. Backend validates the live Subject
/// ceiling and requested Profile; Ticket assignment grants no capabilities.
#[serde(default)]
execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
}
// Serde's ordinary Option would collapse explicit null and an absent field.
@@ -170,7 +174,7 @@ impl Tool for SpawnTicketWorkerTool {
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
display_name: format!("Worker · {}", ticket.resource_key),
initial_submit,
execution_grant: None,
execution_grant: input.execution_grant,
})
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
@@ -339,6 +343,12 @@ mod tests {
let mut input = input();
input["flow"] = serde_json::json!("project:investigation");
input["orchestrator_assignment_id"] = serde_json::json!("delegation-742");
input["execution_grant"] = serde_json::json!({
"permissions": [{"operation":"ticket.read","resource":{"kind":"ticket","workspace_id":"workspace-1","ticket_id":"T-719"}}],
"redelegation": []
});
let expected_grant: server_api::authorization::WorkerDelegationRequest =
serde_json::from_value(input["execution_grant"].clone()).unwrap();
input["workdir_attachments"] = serde_json::json!([
{
"alias": "checkout",
@@ -359,6 +369,7 @@ mod tests {
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert_eq!(request.runtime_id, "runtime-1");
assert_eq!(request.execution_grant.as_ref(), Some(&expected_grant));
assert_eq!(
request.orchestrator_assignment_id.as_deref(),
Some("delegation-742")
@@ -145,13 +145,13 @@ impl PublicAssignmentKind {
pub(super) fn description(name: &str) -> &'static str {
match name {
"TicketOrchestratorAssign" => {
"Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
"Explicitly delegate or reissue this Ticket to a target Subject principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
}
"TicketOrchestratorClear" => {
"Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
"Release the observed current Subject delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
}
"TicketAssignmentsQuery" => {
"Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
"Read a bounded page of retained Subject assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
}
"TicketAssignmentDiagnostics" => {
"Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state."
@@ -343,19 +343,8 @@ async fn execute_value(
identity("operation_id", &input.operation_id)?;
optional_identity("expected_assignment_id", &input.expected_assignment_id)?;
match &input.principal {
server_api::TicketAssignmentPrincipal::User { account_id } => {
identity("account_id", account_id)?
}
server_api::TicketAssignmentPrincipal::Worker {
runtime_id,
worker_id,
} => {
identity("runtime_id", runtime_id)?;
identity("worker_id", worker_id)?;
}
server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
identity("agent_key", agent_key)?
}
server_api::TicketAssignmentPrincipal::Subject { subject_id } => identity("subject_id", subject_id)?,
_ => return Err(ToolError::InvalidArgument("New Ticket responsibility requires an explicit Subject principal; legacy principals are audit-only".into())),
}
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id: input.ticket,
@@ -46,7 +46,7 @@ pub(super) fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
vec![
(
PublicAssignmentKind::Assign,
json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
json!({"ticket":"T-743", "principal":{"kind":"subject", "subject_id":"subject-a"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
),
(
@@ -164,6 +164,26 @@ async fn public_assignment_schemas_require_observations_and_exclude_model_actor_
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn new_assignment_rejects_retained_legacy_principals_before_transport() {
for principal in [
json!({"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}),
json!({"kind":"user", "account_id":"owner"}),
json!({"kind":"worker", "runtime_id":"r", "worker_id":"old-body"}),
] {
let client = transport(200, json!({}));
let (_, tool) = definition(client.clone(), PublicAssignmentKind::Assign)();
let input = json!({"ticket":"T-743", "principal":principal,
"expected_assignment_id":null, "operation_id":"legacy-is-not-new-authority"});
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() {
for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| {
@@ -79,7 +79,7 @@ pub(super) fn description(name: &str) -> &'static str {
"Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request."
}
"TicketAssignmentTrackingUpdate" => {
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
"Record the current assigned Subject's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current Subject body; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
}
_ => unreachable!("unknown assignment tracking tool"),
}
@@ -99,6 +99,10 @@ fn principal() -> Shape {
union(
"kind",
vec![
case(
"subject",
Some(record(vec![p("subject_id", true, Shape::String)])),
),
case(
"user",
Some(record(vec![p("account_id", true, Shape::String)])),
@@ -117,6 +121,15 @@ fn principal() -> Shape {
],
)
}
fn assignment_principal() -> Shape {
union(
"kind",
vec![case(
"subject",
Some(record(vec![p("subject_id", true, Shape::String)])),
)],
)
}
fn reason() -> Shape {
// An externally tagged domain enum is encoded by this single native union definition.
Shape::ExternalUnion {
@@ -387,7 +400,7 @@ pub(super) fn contract(op: &str) -> Contract {
"orchestrator_assign",
"Delegate or explicitly reissue responsibility; no Worker starts. Observed null means unassigned, not unknown. Exact retries retain operation identity and full input.",
vec![
p("principal", true, principal()),
p("principal", true, assignment_principal()),
group(
"observed",
true,
@@ -198,12 +198,32 @@ fn native_entry_inputs_use_allocated_namespace_and_never_subject_argument_ids()
#[test]
fn assignment_principal_and_cas_are_declared_compounds_not_opaque_json() {
let value = json!({"principal":{"$case":"worker","value":{"runtime_id":"r","worker_id":"w"}},"observed":{"assignment_id":{"$case":"null"}},"operation_id":"assign-1"});
let value = json!({"principal":{"$case":"subject","value":{"subject_id":"subject-a"}},"observed":{"assignment_id":{"$case":"null"}},"operation_id":"assign-1"});
let command = converted("orchestrator_assign", value.clone(), Some("T-42"));
assert_eq!(
command,
json!({"ticket":"T-42","principal":{"kind":"worker","runtime_id":"r","worker_id":"w"},"expected_assignment_id":null,"operation_id":"assign-1"})
json!({"ticket":"T-42","principal":{"kind":"subject","subject_id":"subject-a"},"expected_assignment_id":null,"operation_id":"assign-1"})
);
for legacy in [
json!({"$case":"worker","value":{"runtime_id":"r","worker_id":"w"}}),
json!({"$case":"user","value":{"account_id":"a"}}),
json!({"$case":"workspace_agent","value":{"agent_key":"workspace-orchestrator"}}),
] {
let mut assignment = value.clone();
assignment["principal"] = legacy;
assert!(
input(
"orchestrator_assign",
&arguments(assignment),
Some("T-42"),
Some("ticket"),
"/cases",
"/changes",
"/goals"
)
.is_err()
);
}
for invalid in [
{
let mut v = value.clone();
+3 -3
View File
@@ -606,7 +606,7 @@ mod tests {
"only `open`",
"stable tag IDs, not display names",
"no ready step or separate Queue is required",
"Orchestrator's self-reported handling",
"assigned Subject's self-reported handling",
"Answers during waiting or ongoing handling continue the same assignment row",
"explicit reissue creates a new row/assignment_id",
"Respect an explicit user approval condition",
@@ -684,9 +684,9 @@ mod tests {
)));
for (policy, name, input) in [
(
"Use `TicketOrchestratorAssign` for explicit initial delegation or reissue",
"Use `TicketOrchestratorAssign` for explicit initial assignment, reassignment or reissue to the selected Subject principal",
"TicketOrchestratorAssign",
serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
serde_json::json!({"ticket":"T-743","principal":{"kind":"subject","subject_id":"subject-a"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
),
(
"research returned to a human can be `open` + handling `done`",
@@ -5,7 +5,7 @@
//! Retained migration SQL establishes origins only for reconciled assignment-specific legacy
//! Queue fences; migration does not reinterpret historical comments as new requests.
//!
//! Reserve and prepare recheck delegation, singleton and live binding under
//! Reserve and prepare recheck delegation, Subject body and live binding under
//! IMMEDIATE ownership. ACK instead verifies the target authorized before I/O;
//! a later responsibility change must not erase Runtime acceptance evidence.
//! Persist the entire rendered request before Runtime I/O, then reuse
@@ -199,7 +199,7 @@ fn source_guard(
workspace_id,
&source.runtime_id,
&source.worker_id,
&source.agent_key,
&source.subject_id,
&source.authentication_binding_id,
)
}
@@ -226,7 +226,31 @@ pub(super) fn require_current(
Ok(())
}
// Call only after source_guard and require_current in the reservation/preparation
// Runtime source authentication alone is insufficient: two live Subject bodies can
// coexist in one Workspace. Fence the recipient's Subject before any receipt replay
// or target authorization, in the same write transaction as the delivery/start.
fn require_assignment_target(
conn: &Connection,
workspace_id: &str,
ticket_id: &str,
assignment_id: &str,
subject_id: &str,
) -> Result<()> {
require_current(conn, workspace_id, ticket_id, assignment_id)?;
let matches: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND role='orchestrator' AND principal_kind='subject' AND principal_id=?4)",
params![workspace_id, ticket_id, assignment_id, subject_id],
|row| row.get(0),
)?;
if !matches {
return Err(Error::WorkspacePermissionDenied(
"Delivery/start target Subject differs from the current assignment".into(),
));
}
Ok(())
}
// Call only after source_guard and require_assignment_target in the reservation/preparation
// transaction. A replacement target gets its own evidence for the same delivery,
// not a new request or authority to revive a completed/released assignment.
fn save_authorized_target(
@@ -242,13 +266,30 @@ fn save_authorized_target(
delivery,
source.runtime_id,
source.worker_id,
source.agent_key,
source.subject_id,
source.authentication_binding_id
],
)?;
Ok(())
}
fn is_acknowledged(conn: &Connection, workspace: &str, delivery: &str) -> Result<bool> {
Ok(conn.query_row(
"SELECT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2)",
params![workspace, delivery], |row| row.get(0),
)?)
}
fn require_unacknowledged(conn: &Connection, workspace: &str, delivery: &str) -> Result<()> {
if is_acknowledged(conn, workspace, delivery)? {
return Err(Error::TicketAssignmentConflict(
"Assignment delivery already has Runtime acceptance; new send admission is closed"
.into(),
));
}
Ok(())
}
pub(super) fn reserve(
store: &SqliteWorkspaceStore,
workspace_id: &str,
@@ -260,9 +301,9 @@ pub(super) fn reserve(
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
source_guard(&tx,workspace_id,source)?;
let ticket_id:String=tx.query_row("SELECT ticket_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,assignment_id],|r|r.get(0))?;
require_current(&tx,workspace_id,&ticket_id,assignment_id)?;
require_assignment_target(&tx,workspace_id,&ticket_id,assignment_id,&source.subject_id)?;
let saved:Option<String>=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND assignment_id=?2 AND input_event_id=?3",params![workspace_id,assignment_id,input_event_id],|r|r.get(0)).optional()?;
if let Some(saved)=saved {let result=decode_payload(&saved)?;save_authorized_target(&tx,workspace_id,&result.delivery_id,source)?;tx.commit()?;return Ok(result);}
if let Some(saved)=saved {let result=decode_payload(&saved)?;require_unacknowledged(&tx,workspace_id,&result.delivery_id)?;save_authorized_target(&tx,workspace_id,&result.delivery_id,source)?;tx.commit()?;return Ok(result);}
// A caller cannot fabricate an input. Restrict the same common query by
// exact assignment/input identity rather than trusting a scan snapshot.
let candidates=inputs_in_tx(&tx,workspace_id,1,Some(assignment_id),Some(input_event_id))?;
@@ -295,7 +336,8 @@ pub(super) fn prepare_content(
source_guard(&tx,workspace_id,source)?;
let payload:String=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0))?;
let delivery=decode_payload(&payload)?;
require_current(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id)?;
require_assignment_target(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id,&source.subject_id)?;
require_unacknowledged(&tx,workspace_id,delivery_id)?;
save_authorized_target(&tx,workspace_id,delivery_id,source)?;
let saved:Option<String>=tx.query_row("SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?;
let result=if let Some(saved)=saved {saved} else {
@@ -307,6 +349,46 @@ pub(super) fn prepare_content(
})
}
/// Final send admission is distinct from reading a receipt or replaying an ACK.
/// Recheck the current Subject/assignment and prepared target, then return only
/// the pinned bytes. A known ACK closes admission; an unknown outcome keeps the
/// same request eligible. The caller still authorizes live policy immediately
/// before Runtime I/O; the transaction does not serialize external acceptance.
pub(super) fn admit_send(
store: &SqliteWorkspaceStore,
workspace_id: &str,
delivery_id: &str,
source: &TicketAssignmentTrackingSourceContext,
) -> Result<Option<String>> {
store.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
source_guard(&tx, workspace_id, source)?;
let payload: String = tx.query_row(
"SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",
params![workspace_id, delivery_id], |row| row.get(0),
)?;
let delivery = decode_payload(&payload)?;
require_assignment_target(&tx, workspace_id, &delivery.ticket_id, &delivery.assignment_id, &source.subject_id)?;
let authorized: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",
params![workspace_id, delivery_id, source.runtime_id, source.worker_id, source.subject_id, source.authentication_binding_id], |row| row.get(0),
)?;
if !authorized {
return Err(Error::WorkspacePermissionDenied("Assignment send target was not prepared or reserved for this delivery".into()));
}
let content = if is_acknowledged(&tx, workspace_id, delivery_id)? {
None
} else {
Some(tx.query_row(
"SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",
params![workspace_id, delivery_id], |row| row.get(0),
).optional()?.ok_or_else(|| Error::TicketAssignmentConflict("Assignment send requires a persisted request".into()))?)
};
tx.commit()?;
Ok(content)
})
}
pub(super) fn ack(
store: &SqliteWorkspaceStore,
workspace_id: &str,
@@ -319,7 +401,7 @@ pub(super) fn ack(
// authenticated target identity fields against evidence saved while current.
// Actor is audit attribution; it is not target authority. Existing ACK bytes
// still have to match on replay and are never replaced.
let authorized:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",params![workspace_id,delivery_id,source.runtime_id,source.worker_id,source.agent_key,source.authentication_binding_id],|r|r.get(0))?;
let authorized:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",params![workspace_id,delivery_id,source.runtime_id,source.worker_id,source.subject_id,source.authentication_binding_id],|r|r.get(0))?;
if !authorized {return Err(Error::WorkspacePermissionDenied("Acknowledgement target was not authorized for this delivery".into()));}
let source_json=serde_json::to_string(source).map_err(|e|Error::Store(e.to_string()))?;
let saved:Option<String>=tx.query_row("SELECT source_json FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?;
@@ -339,16 +421,16 @@ pub(super) fn operation_guard(
operation_id: &str,
) -> Result<()> {
let fence=conn.query_row("SELECT o.ticket_id,f.orchestrator_assignment_id,f.runtime_id,f.worker_id,f.agent_key,f.authentication_binding_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?))).optional()?;
if let Some((ticket_id, assignment_id, runtime_id, worker_id, agent_key, binding_id)) = fence {
if let Some((ticket_id, assignment_id, runtime_id, worker_id, subject_id, binding_id)) = fence {
assignment_tracking::require_runtime_source(
conn,
workspace_id,
&runtime_id,
&worker_id,
&agent_key,
&subject_id,
&binding_id,
)?;
require_current(conn, workspace_id, &ticket_id, &assignment_id)?;
require_assignment_target(conn, workspace_id, &ticket_id, &assignment_id, &subject_id)?;
}
Ok(())
}
@@ -363,7 +445,13 @@ pub(super) fn save_operation_fence(
existing: bool,
) -> Result<()> {
source_guard(conn, workspace_id, source)?;
require_current(conn, workspace_id, ticket_id, assignment_id)?;
require_assignment_target(
conn,
workspace_id,
ticket_id,
assignment_id,
&source.subject_id,
)?;
let source_json = serde_json::to_string(source).map_err(|e| Error::Store(e.to_string()))?;
let saved:Option<(String,String)>=conn.query_row("SELECT orchestrator_assignment_id,source_json FROM ticket_assignment_operation_fences WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
if let Some(saved) = saved {
@@ -386,7 +474,7 @@ pub(super) fn save_operation_fence(
assignment_id,
source.runtime_id,
source.worker_id,
source.agent_key,
source.subject_id,
source.authentication_binding_id,
source_json
],
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,7 @@ pub enum TicketOrchestratorAssignmentSourceContext {
Runtime {
runtime_id: String,
worker_id: String,
#[serde(rename = "subject_id")]
agent_key: String,
authentication_binding_id: String,
},
@@ -46,9 +47,25 @@ pub(super) fn require_runtime_source(
agent_key: &str,
binding_id: &str,
) -> Result<()> {
let current_source:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM worker_singleton_owners singleton JOIN workspace_runtime_bindings binding ON binding.workspace_id=singleton.workspace_id AND binding.runtime_id=singleton.runtime_id WHERE singleton.workspace_id=?1 AND singleton.singleton_key=?2 AND singleton.runtime_id=?3 AND singleton.worker_id=?4 AND binding.binding_id=?5 AND binding.revoked_at IS NULL)",params![workspace_id,crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,runtime_id,worker_id,binding_id],|row|row.get(0))?;
if agent_key != "workspace-orchestrator" || binding_id.trim().is_empty() || !current_source {
return Err(Error::WorkspacePermissionDenied("Assignment source is not the current registered Orchestrator with its authenticated Runtime binding".into()));
// The legacy field name is retained in immutable source receipts, but only a
// Subject identity can authenticate this self-report/delivery boundary.
// In particular, never reinterpret the old workspace-agent identity.
if agent_key == "workspace-orchestrator" || agent_key.trim().is_empty() {
return Err(Error::WorkspacePermissionDenied(
"Legacy assignment sources are audit-only".into(),
));
}
let key = crate::subjektiv::subject_worker_singleton_key(agent_key)
.map_err(|error| Error::WorkspacePermissionDenied(error.to_string()))?;
let current_source: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM worker_singleton_owners s
JOIN worker_create_reservations r ON r.workspace_id=s.workspace_id AND r.runtime_id=s.runtime_id AND r.worker_id=s.worker_id AND r.singleton_key=s.singleton_key AND r.singleton_generation=s.generation AND r.state!='removed'
JOIN workspace_runtime_bindings b ON b.workspace_id=s.workspace_id AND b.runtime_id=s.runtime_id
WHERE s.workspace_id=?1 AND s.singleton_key=?2 AND s.runtime_id=?3 AND s.worker_id=?4 AND b.binding_id=?5 AND b.revoked_at IS NULL
AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.workspace_id=s.workspace_id AND d.subject_id=?6))",
params![workspace_id, key, runtime_id, worker_id, binding_id, agent_key], |row| row.get(0))?;
if binding_id.trim().is_empty() || !current_source {
return Err(Error::WorkspacePermissionDenied("Assignment source is not the current Subject body with its authenticated Runtime binding and generation".into()));
}
Ok(())
}
@@ -64,14 +81,25 @@ pub(super) fn require_assignment_source(
worker_id,
agent_key,
authentication_binding_id,
} => require_runtime_source(
conn,
workspace_id,
runtime_id,
worker_id,
agent_key,
authentication_binding_id,
),
} => {
let live: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_runtime_bindings b JOIN worker_registry w ON w.workspace_id=b.workspace_id AND w.runtime_id=b.runtime_id WHERE b.workspace_id=?1 AND b.runtime_id=?2 AND b.binding_id=?3 AND b.revoked_at IS NULL AND w.worker_id=?4)",params![workspace_id,runtime_id,authentication_binding_id,worker_id], |r|r.get(0))?;
if !live || agent_key == "workspace-orchestrator" {
return Err(Error::WorkspacePermissionDenied(
"Assignment source binding is no longer live".into(),
));
}
if !agent_key.is_empty() {
require_runtime_source(
conn,
workspace_id,
runtime_id,
worker_id,
agent_key,
authentication_binding_id,
)?;
}
Ok(())
}
TicketOrchestratorAssignmentSourceContext::Account {
account_id,
user_id,
@@ -110,7 +138,7 @@ pub(super) fn update(
workspace_id,
&source.runtime_id,
&source.worker_id,
&source.agent_key,
&source.subject_id,
&source.authentication_binding_id,
)?;
let result = ticket::sqlite_update_ticket_assignment_tracking_in_transaction(
@@ -4,7 +4,7 @@ fn tracking_store_source() -> TicketAssignmentTrackingSourceContext {
actor: "authenticated-worker:r/worker".into(),
runtime_id: "r".into(),
worker_id: "worker".into(),
agent_key: "workspace-orchestrator".into(),
subject_id: "tracking-subject".into(),
authentication_binding_id: "tracking-binding".into(),
}
}
@@ -14,8 +14,9 @@ async fn tracking_store_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
seed_manual_start_ticket(&store, "tracking", "open");
store.with_conn(|conn| {
conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('w','r','Runtime','https://runtime.invalid','verified-fixture-key','verified-fixture-fingerprint','tracking-binding','workspace_identity','1','1')",[])?;
conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",[crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY])?;
conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",["subjektiv:tracking-subject"])?;
conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','replacement','Replacement','normal','1','1')",[])?;
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at,singleton_key,singleton_generation) VALUES('w','tracking-body','worker','r','tracking-body','created','1','1','subjektiv:tracking-subject',1)",[])?;
Ok(())
}).unwrap();
(directory, store)
@@ -29,8 +30,8 @@ fn tracking_store_assign(
let assignment = TicketRoleAssignmentRecord {
assignment_id: id.into(),
role: TicketAssignmentRole::Orchestrator,
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
principal: TicketAssignmentPrincipal::Subject {
subject_id: "tracking-subject".into(),
},
..manual_worker_assignment("tracking", id)
};
@@ -62,7 +63,15 @@ fn tracking_store_input(id: &str, event: &str, key: &str) -> TicketAssignmentTra
#[tokio::test]
async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_binding_under_write_lock()
{
for change in ["replacement", "revoke", "binding-change", "remove"] {
for change in [
"replacement",
"revoke",
"binding-change",
"generation",
"reservation-removed",
"subject-deleted",
"remove",
] {
let (directory, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let source = tracking_store_source();
@@ -86,6 +95,9 @@ async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_
"replacement" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;},
"revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;},
"binding-change" => {tx.execute("UPDATE workspace_runtime_bindings SET binding_id='new-binding' WHERE workspace_id='w'",[])?;},
"generation" => {tx.execute("UPDATE worker_create_reservations SET singleton_generation=2 WHERE workspace_id='w'",[])?;},
"reservation-removed" => {tx.execute("UPDATE worker_create_reservations SET state='removed' WHERE workspace_id='w'",[])?;},
"subject-deleted" => {tx.execute("INSERT INTO subject_deletions(workspace_id,subject_id,operation_id) VALUES('w','tracking-subject','delete-subject')",[])?;},
_ => {tx.execute("DELETE FROM worker_singleton_owners WHERE workspace_id='w'",[])?;},
}
tx.commit()?;Ok(())
@@ -470,17 +482,37 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
95,
"Common notification routing and durable Worker delivery".into()
),
(96,"Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into()),
(96, "Backend Job common notification routing".into()),
(
97,
"MR notifications and authenticated implementation results".into()
),
(98, "Assignment-independent implementation reports".into()),
(99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()),
(
99,
"Explicit hosting administrators and self-registration policy".into()
),
(
100,
"Provisional users and scoped passkey activation".into()
),
(101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into()),
(104, "Converge Passkey results and Job input retention".into()),
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
(106, crate::plugin_catalog::MIGRATION_NAME.into())
(
104,
"Converge Passkey results and Job input retention".into()
),
(
105,
"Durable Subject deletion snapshots and Job admission fences".into()
),
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
(
107,
"Subject Ticket responsibility and current-body notification routing"
.into()
)
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
@@ -554,10 +586,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
}
#[test]
fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first_self_report() {
fn tracking_store_schema91_closed_unknown_assignment_retains_assign_audit_but_legacy_cannot_self_report()
{
use ticket::TicketBackend;
for report_after_reopen in [false, true] {
for reopen_before_report in [false, true] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("closed-unknown91.db");
let conn = tracking_schema91_fixture(&path);
@@ -568,27 +600,8 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
let grants_before = stored_fixture_rows(&conn, "worker_control_grants");
let releases_before = stored_fixture_rows(&conn, "ticket_assignment_work_releases");
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap();
let reopen = || {
let observed = backend.show("historical-done".into()).unwrap();
backend
.update_case(
"historical-done",
ticket::TicketCaseUpdate {
expected_content_digest: ticket::ticket_content_digest(&observed),
expected_state: ticket::TicketState::Closed,
expected_closure_operation_key: observed.closure_operation_key.clone(),
operation_key: "explicit-reopen".into(),
action: ticket::TicketCaseAction::Reopen,
explanation: "Reopen without redelegation or restoring old work".into(),
references: vec![],
author: Some("account-author".into()),
},
)
.unwrap();
};
let unknown = store
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
.unwrap()
@@ -602,11 +615,59 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
Some("assigned-historical-done")
);
assert_eq!(
store
.list_current_ticket_assignment_tracking("w", "historical-done")
.unwrap(),
vec![unknown.clone()]
unknown.principal,
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
agent_key: "workspace-orchestrator".into()
}
);
if reopen_before_report {
let observed = backend.show("historical-done".into()).unwrap();
backend
.update_case(
"historical-done",
ticket::TicketCaseUpdate {
expected_content_digest: ticket::ticket_content_digest(&observed),
expected_state: ticket::TicketState::Closed,
expected_closure_operation_key: observed.closure_operation_key,
operation_key: "explicit-reopen".into(),
action: ticket::TicketCaseAction::Reopen,
explanation: "Reopen is not Subject assignment or Worker restore".into(),
references: vec![],
author: Some("account-author".into()),
},
)
.unwrap();
}
let mut legacy = tracking_store_source();
legacy.subject_id = "workspace-orchestrator".into();
let input = tracking_store_input(
"a-historical-done",
unknown.last_event_id.as_deref().unwrap(),
"legacy-result",
);
// Both the old identity and an unrelated Subject must fail even when
// they possess the authentic historical Assign observation.
let second = SqliteWorkspaceStore::open_for_test(&path).unwrap();
for source in [&legacy, &tracking_store_source()] {
for observer in [&store, &second] {
assert!(matches!(
observer.update_ticket_assignment_tracking(
"w",
"historical-done",
&input,
source
),
Err(Error::WorkspacePermissionDenied(_))
));
assert_eq!(
observer
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
.unwrap()
.unwrap(),
unknown
);
}
}
assert!(
store
.list_ticket_assignment_tracking_history(
@@ -617,129 +678,6 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
.unwrap()
.is_empty()
);
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 13);
assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='historical-done'", [], |row| row.get::<_,String>(0))?, "closed");
let stored: (Option<String>, Option<String>) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
assert_eq!(stored, (None, None), "read baseline must not manufacture tracking storage");
assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before);
Ok(())
}).unwrap();
if report_after_reopen {
reopen();
}
let source = tracking_store_source();
let input = tracking_store_input(
"a-historical-done",
unknown.last_event_id.as_deref().unwrap(),
"first-explicit-result",
);
let mut wrong_source = source.clone();
wrong_source.worker_id = "not-current-orchestrator".into();
assert!(matches!(
store.update_ticket_assignment_tracking("w", "historical-done", &input, &wrong_source),
Err(Error::WorkspacePermissionDenied(_))
));
for (workspace, ticket) in [("other-workspace", "historical-done"), ("w", "pending")] {
assert!(
store
.update_ticket_assignment_tracking(workspace, ticket, &input, &source)
.is_err()
);
}
assert!(
store
.update_ticket_assignment_tracking(
"w",
"replaced",
&tracking_store_input(
"a-replaced",
"assigned-replaced",
"wrong-old-assignment"
),
&source
)
.is_err()
);
// A second observer holds the same Assign baseline. Only the first
// explicit writer may consume it; BEGIN IMMEDIATE serializes the CAS.
let second = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert_eq!(
second
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
.unwrap()
.unwrap(),
unknown
);
let done = store
.update_ticket_assignment_tracking("w", "historical-done", &input, &source)
.unwrap();
assert_eq!(
done.tracking.as_ref().unwrap().status,
TicketAssignmentTrackingStatus::Done
);
assert_eq!(done.assignment_id, unknown.assignment_id);
assert_ne!(done.last_event_id, unknown.last_event_id);
assert_eq!(
store
.update_ticket_assignment_tracking("w", "historical-done", &input, &source)
.unwrap(),
done
);
assert!(matches!(
second.update_ticket_assignment_tracking(
"w",
"historical-done",
&tracking_store_input(
"a-historical-done",
"assigned-historical-done",
"stale-second-observer"
),
&source
),
Err(Error::TicketAssignmentConflict(_))
));
assert_eq!(
backend
.show("historical-done".into())
.unwrap()
.meta
.workflow_state,
if report_after_reopen {
ticket::TicketState::Open
} else {
ticket::TicketState::Closed
}
);
if !report_after_reopen {
reopen();
}
let after_reopen = store
.update_ticket_assignment_tracking(
"w",
"historical-done",
&tracking_store_input(
"a-historical-done",
done.last_event_id.as_deref().unwrap(),
"explicit-result-after-reopen",
),
&source,
)
.unwrap();
let history = store
.list_ticket_assignment_tracking_history("w", "historical-done", "a-historical-done")
.unwrap();
assert_eq!(
history.len(),
2,
"only explicit self-reports create handling events"
);
assert_eq!(history[0].event_id, done.last_event_id.clone().unwrap());
assert_eq!(
history[1].event_id,
after_reopen.last_event_id.clone().unwrap()
);
assert!(
store
.get_active_ticket_role_assignment(
@@ -751,56 +689,40 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
.is_none()
);
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id='w' AND ticket_id='historical-done'", [], |row| row.get::<_,i64>(0))?, 1);
assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before);
assert_eq!(stored_fixture_rows(conn, "worker_control_grants"), grants_before);
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_work_releases"), releases_before);
let stored: (Option<String>, Option<String>) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
assert_eq!(stored, (None, None), "Assign audit baseline must not manufacture handling or authority");
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| row.get::<_,i64>(0))?, 0);
Ok(())
}).unwrap();
assert_eq!(
backend
.show("historical-done".into())
.unwrap()
.meta
.workflow_state,
if reopen_before_report {
ticket::TicketState::Open
} else {
ticket::TicketState::Closed
}
);
drop(second);
drop(store);
let reopened_store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert_eq!(
reopened_store
reopened
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
.unwrap()
.unwrap(),
after_reopen
);
assert!(
reopened_store
.clear_current_ticket_orchestrator_assignment(
"w",
"historical-done",
"a-historical-done",
"explicit-release-event",
"explicit-release",
"human",
"3",
Some("Return responsibility"),
&orchestrator_mutation_runtime_source()
)
.unwrap()
unknown
);
assert!(matches!(
reopened_store.update_ticket_assignment_tracking(
"w",
"historical-done",
&tracking_store_input(
"a-historical-done",
after_reopen.last_event_id.as_deref().unwrap(),
"new-report-after-release"
),
&source
),
Err(Error::TicketAssignmentConflict(_))
reopened.update_ticket_assignment_tracking("w", "historical-done", &input, &legacy),
Err(Error::WorkspacePermissionDenied(_))
));
let released = reopened_store
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
.unwrap()
.unwrap();
assert!(!released.current);
assert_eq!(released.tracking, after_reopen.tracking);
}
}
@@ -858,7 +780,7 @@ fn orchestrator_mutation_runtime_source() -> TicketOrchestratorAssignmentSourceC
TicketOrchestratorAssignmentSourceContext::Runtime {
runtime_id: "r".into(),
worker_id: "worker".into(),
agent_key: "workspace-orchestrator".into(),
agent_key: "tracking-subject".into(),
authentication_binding_id: "tracking-binding".into(),
}
}
@@ -866,8 +788,8 @@ fn orchestrator_mutation_record(id: &str) -> TicketRoleAssignmentRecord {
TicketRoleAssignmentRecord {
assignment_id: id.into(),
role: TicketAssignmentRole::Orchestrator,
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
principal: TicketAssignmentPrincipal::Subject {
subject_id: "tracking-subject".into(),
},
assigned_by: "readable Backend attribution".into(),
..manual_worker_assignment("tracking", id)
@@ -984,7 +906,7 @@ async fn orchestrator_assignment_receipts_bind_verified_source_even_when_readabl
authentication_binding_id: "replacement-binding".into(),
runtime_id: "r".into(),
worker_id: "worker".into(),
agent_key: "workspace-orchestrator".into(),
agent_key: "tracking-subject".into(),
};
assert!(matches!(
store.set_current_ticket_orchestrator_assignment(
@@ -1349,8 +1271,8 @@ async fn tracking_history_pages_are_append_ordered_exact_scope_cursors_and_bound
seed_manual_start_ticket(&store, "another-ticket", "open");
let other_ticket = TicketRoleAssignmentRecord {
role: TicketAssignmentRole::Orchestrator,
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
principal: TicketAssignmentPrincipal::Subject {
subject_id: "tracking-subject".into(),
},
..manual_worker_assignment("another-ticket", "other-ticket-assignment")
};
@@ -1521,7 +1443,7 @@ fn assignment_answer_source() -> TicketOrchestratorAssignmentSourceContext {
TicketOrchestratorAssignmentSourceContext::Runtime {
runtime_id: "r".into(),
worker_id: "worker".into(),
agent_key: "workspace-orchestrator".into(),
agent_key: "tracking-subject".into(),
authentication_binding_id: "tracking-binding".into(),
}
}
@@ -1711,8 +1633,8 @@ async fn assignment_answer_new_input_rejects_replaced_or_changed_handling_but_ex
);
let mut replacement = manual_worker_assignment("tracking", "answer-b");
replacement.role = TicketAssignmentRole::Orchestrator;
replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
replacement.principal = TicketAssignmentPrincipal::Subject {
subject_id: "tracking-subject".into(),
};
store
.set_current_ticket_role_assignment(
@@ -1775,8 +1697,8 @@ async fn assignment_identity_pages_keep_release_and_last_status_separate_and_rej
tracking_store_assign(&store, "z-first", "first");
let mut replacement = manual_worker_assignment("tracking", "a-second");
replacement.role = TicketAssignmentRole::Orchestrator;
replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
replacement.principal = TicketAssignmentPrincipal::Subject {
subject_id: "tracking-subject".into(),
};
store
.set_current_ticket_role_assignment(
+12 -2
View File
@@ -458,6 +458,12 @@ impl SqliteWorkspaceAuthority {
];
if let Some(principal) = &query.assigned_principal {
match principal {
ticket::TicketAssignmentPrincipalSnapshot::Subject { subject_id } => {
let value = bind(SqlValue::Text(subject_id.clone()));
assignment.push(format!(
"a.principal_kind='subject' AND a.principal_id={value}"
));
}
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
let value = bind(SqlValue::Text(agent_key.clone()));
assignment.push(format!(
@@ -854,6 +860,9 @@ impl SqliteWorkspaceAuthority {
runtime_id,
worker_id,
},
TicketAssignmentPrincipal::Subject { subject_id } => {
TicketAssignmentPrincipalSummary::Subject { subject_id }
}
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
TicketAssignmentPrincipalSummary::WorkspaceAgent { agent_key }
}
@@ -2136,6 +2145,7 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
}
if let Some(principal) = &query.assigned_principal {
let ids: Vec<&str> = match principal {
ticket::TicketAssignmentPrincipalSnapshot::Subject { subject_id } => vec![subject_id],
ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => vec![account_id],
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
vec![agent_key]
@@ -3614,8 +3624,8 @@ mod tests {
let orchestrator = crate::store::TicketRoleAssignmentRecord {
role: TicketAssignmentRole::Orchestrator,
assignment_id: "orchestrator".into(),
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
principal: TicketAssignmentPrincipal::Subject {
subject_id: "eligibility-subject".into(),
},
..record.clone()
};
@@ -9,7 +9,9 @@ fn implementation_result_input() -> server_api::TicketImplementationResultReques
}
}
fn implementation_result_business_state(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
fn implementation_result_business_state(
conn: &Connection,
) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
[
"typed_tickets",
"typed_ticket_events",
@@ -63,21 +65,50 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
let receipt = store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.unwrap();
assert_eq!(store.with_conn(|conn| Ok(implementation_result_business_state(conn))).unwrap(), before);
assert_eq!(store.implementation_result("w", "tracking", &receipt.result_id).unwrap(), receipt);
assert_eq!(
store
.with_conn(|conn| Ok(implementation_result_business_state(conn)))
.unwrap(),
before
);
assert_eq!(
store
.implementation_result("w", "tracking", &receipt.result_id)
.unwrap(),
receipt
);
// A second, independently authenticated Worker cannot reuse this Workspace operation key.
let other = RuntimeWorkerRef::new("r", "worker");
assert!(store.record_implementation_result("w", "tracking", &input, &other, "tracking-binding").is_err());
assert!(
store
.record_implementation_result("w", "tracking", &input, &other, "tracking-binding")
.is_err()
);
let mut other_input = input.clone();
other_input.operation_key = "other-worker-result".into();
store.record_implementation_result("w", "tracking", &other_input, &other, "tracking-binding").unwrap();
store
.record_implementation_result("w", "tracking", &other_input, &other, "tracking-binding")
.unwrap();
seed_manual_start_ticket(&store, "other-ticket", "open");
assert!(store.record_implementation_result("w", "other-ticket", &input, &worker, "tracking-binding").is_err());
assert!(
store
.record_implementation_result("w", "other-ticket", &input, &worker, "tracking-binding")
.is_err()
);
let mut missing = input.clone();
missing.operation_key = "missing-ticket-result".into();
assert!(store.record_implementation_result("w", "missing", &missing, &worker, "tracking-binding").is_err());
assert!(!store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().iter()
.any(|p| p.input_event_id.starts_with("implementation-result:")));
assert!(
store
.record_implementation_result("w", "missing", &missing, &worker, "tracking-binding")
.is_err()
);
assert!(
!store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.iter()
.any(|p| p.input_event_id.starts_with("implementation-result:"))
);
let evidence = store.with_conn(|conn| {
let source: (String, String, String) = conn.query_row(
"SELECT s.assignment_id,d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1",
@@ -90,9 +121,19 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
}).unwrap();
// A later Orchestrator must not acquire previously recipient-less reports.
tracking_store_assign(&store, "late-delegation", "assign-late");
assert_eq!(store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
assert!(!store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().iter()
.any(|p| p.input_event_id.starts_with("implementation-result:")));
assert_eq!(
store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.unwrap(),
receipt
);
assert!(
!store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.iter()
.any(|p| p.input_event_id.starts_with("implementation-result:"))
);
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 2);
// Assignment creation has its own evidence; old report evidence stays byte-for-byte intact.
@@ -101,22 +142,46 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
Ok(())
}).unwrap();
let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
assert_eq!(reopened.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
assert_eq!(reopened.implementation_result("w", "tracking", &receipt.result_id).unwrap(), receipt);
assert_eq!(
reopened
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.unwrap(),
receipt
);
assert_eq!(
reopened
.implementation_result("w", "tracking", &receipt.result_id)
.unwrap(),
receipt
);
}
#[tokio::test]
async fn implementation_result_requires_live_binding_and_registered_worker_even_for_replay() {
for change in ["revoked", "binding-changed", "unregistered", "foreign-worker", "foreign-binding"] {
for change in [
"revoked",
"binding-changed",
"unregistered",
"foreign-worker",
"foreign-binding",
] {
let (_dir, store) = tracking_store_fixture().await;
store.upsert_workspace(&WorkspaceRecord {
workspace_id: "other".into(), owner_account_id: "owner-account".into(),
display_name: "Other".into(), state: "active".into(),
created_at: "1".into(), updated_at: "1".into(),
}).await.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "other".into(),
owner_account_id: "owner-account".into(),
display_name: "Other".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
let worker = RuntimeWorkerRef::new("r", "replacement");
let input = implementation_result_input();
store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap();
store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.unwrap();
store.with_conn(|conn| {
match change {
"revoked" => { conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2' WHERE workspace_id='w'", [])?; }
@@ -131,8 +196,19 @@ async fn implementation_result_requires_live_binding_and_registered_worker_even_
let mut fresh = input.clone();
fresh.operation_key = "fresh-after-auth-change".into();
for request in [&input, &fresh] {
assert!(matches!(store.record_implementation_result("w", "tracking", request, &worker, "tracking-binding"),
Err(Error::WorkspacePermissionDenied(_))), "{change}: {request:?}");
assert!(
matches!(
store.record_implementation_result(
"w",
"tracking",
request,
&worker,
"tracking-binding"
),
Err(Error::WorkspacePermissionDenied(_))
),
"{change}: {request:?}"
);
}
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_implementation_results", [], |r| r.get::<_, i64>(0))?, 1);
@@ -144,7 +220,14 @@ async fn implementation_result_requires_live_binding_and_registered_worker_even_
#[tokio::test]
async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_handling_state() {
for situation in ["never-assigned", "released", "reassigned", "closed", "no-orchestrator", "assigned-before-orchestrator"] {
for situation in [
"never-assigned",
"released",
"reassigned",
"closed",
"no-orchestrator",
"assigned-before-orchestrator",
] {
let (_dir, store) = tracking_store_fixture().await;
if situation != "assigned-before-orchestrator" {
tracking_store_assign(&store, "delegation", "assign");
@@ -157,12 +240,32 @@ async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_h
match situation {
"never-assigned" => {}
"released" => {
assert!(store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Worker,
"implementation", "release-event", "release-op", "human", "3", Some("Released")).unwrap());
assert!(
store
.clear_current_ticket_role_assignment(
"w",
"tracking",
TicketAssignmentRole::Worker,
"implementation",
"release-event",
"release-op",
"human",
"3",
Some("Released")
)
.unwrap()
);
}
"reassigned" => {
store.set_current_ticket_role_assignment(&manual_worker_assignment("tracking", "replacement-implementation"),
Some("implementation"), "reassign-event", "reassign-op", true).unwrap();
store
.set_current_ticket_role_assignment(
&manual_worker_assignment("tracking", "replacement-implementation"),
Some("implementation"),
"reassign-event",
"reassign-op",
true,
)
.unwrap();
}
"closed" => {
store.with_conn(|conn| {
@@ -171,16 +274,40 @@ async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_h
}).unwrap();
}
"no-orchestrator" => {
assert!(store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator,
"delegation", "release-orchestrator-event", "release-orchestrator-op", "human", "3", Some("Returned to human")).unwrap());
assert!(
store
.clear_current_ticket_role_assignment(
"w",
"tracking",
TicketAssignmentRole::Orchestrator,
"delegation",
"release-orchestrator-event",
"release-orchestrator-op",
"human",
"3",
Some("Returned to human")
)
.unwrap()
);
}
"assigned-before-orchestrator" => {
tracking_store_assign(&store, "late-delegation", "assign-late");
}
"assigned-before-orchestrator" => { tracking_store_assign(&store, "late-delegation", "assign-late"); }
_ => unreachable!(),
}
let before = store.with_conn(|conn| Ok(implementation_result_business_state(conn))).unwrap();
let before = store
.with_conn(|conn| Ok(implementation_result_business_state(conn)))
.unwrap();
let input = implementation_result_input();
let receipt = store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap();
assert_eq!(store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
let receipt = store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.unwrap();
assert_eq!(
store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.unwrap(),
receipt
);
store.with_conn(|conn| {
assert_eq!(implementation_result_business_state(conn), before, "{situation}");
let expected = match situation {
@@ -239,9 +366,17 @@ async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
);
let mut changed_kind = input.clone();
changed_kind.kind = server_api::TicketImplementationResultKind::Blocker;
assert!(store
.record_implementation_result("w", "tracking", &changed_kind, &worker, "tracking-binding")
.is_err());
assert!(
store
.record_implementation_result(
"w",
"tracking",
&changed_kind,
&worker,
"tracking-binding"
)
.is_err()
);
for (workspace, ticket, source, binding) in [
("other", "tracking", worker.clone(), "tracking-binding"),
("w", "other", worker.clone(), "tracking-binding"),
@@ -322,11 +457,13 @@ async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
}
Ok(())
}).unwrap();
assert!(store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.iter()
.any(|p| p.input_event_id == format!("implementation-result:{}", late.result_id)));
assert!(
store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.iter()
.any(|p| p.input_event_id == format!("implementation-result:{}", late.result_id))
);
}
#[tokio::test]
@@ -367,131 +504,120 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no
.unwrap();
}
// Preserve seeded assignment evidence while rebuilding the actual pre-business
// capture composition. Changing only a receipt leaves current admission fences
// and erasure guards in an otherwise legacy fixture.
fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> {
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_operations;
DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations;
UPDATE __yoi_schema_migrations SET version=105 WHERE version=106;")?;
// Historical migration fixtures start from the retained Workspace96/component13
// composition. Wrapping the actual connection is test-only SQL access, not a
// current-schema open or any claim of authenticated legacy execution authority.
fn implementation_legacy96_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("retained96.db");
let path = directory.path().join("workspace.db");
prepare_retained_schema(&path, 96);
let retained = Connection::open(path)?;
for trigger in [
"notification_sources_no_delete",
"notification_decisions_no_delete",
"notification_requests_no_delete",
"notification_acknowledgements_no_delete",
"notification_failures_no_delete",
"notification_job_targets_no_delete",
"notification_job_legacy_no_delete",
"notification_source_preparation_failures_no_delete",
] {
let definition: String = retained.query_row(
"SELECT sql FROM sqlite_schema WHERE type='trigger' AND name=?1",
[trigger],
|row| row.get(0),
)?;
conn.execute_batch(&format!("DROP TRIGGER {trigger}; {definition};"))?;
}
for trigger in [
"subject_deleted_job_insert",
"subject_deleted_job_result",
"subject_deleted_attempt_insert",
"subject_deleted_attempt_result",
"subject_deleted_worker_reservation",
"subject_deleted_notification_source",
"subject_deleted_notification_payload",
"subject_deleted_notification_send",
] {
conn.execute_batch(&format!("DROP TRIGGER {trigger};"))?;
}
// Children before parents; dropping the tables also drops snapshot guards.
conn.execute_batch(
"DROP TABLE subject_deletion_removals;
DROP TABLE subject_deletion_allocations;
DROP TABLE subject_deletion_jobs;
DROP TABLE subject_deletion_workers;
DROP TABLE subject_deletions;
UPDATE __yoi_schema_migrations SET version=104 WHERE version=105;",
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 96);
assert_eq!(
conn.query_row(
"SELECT max(version) FROM ticket_schema_migrations",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
13
);
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('legacy-owner','user','legacy-owner','Legacy owner','1','1');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('w','legacy-owner','Legacy workspace','active','1','1');
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','worker','Legacy body','normal','1','1'),('w','r','replacement','Implementation','normal','1','1');
INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('w','r','Runtime','https://runtime.invalid','retained-key','retained-fingerprint','tracking-binding','workspace_identity','1','1');
INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w','workspace-orchestrator','r','worker',1,'1','1');
INSERT INTO repositories(workspace_id,repository_id,repository_key,kind,provider,uri,created_at,updated_at,source_kind,source_uri,source_fingerprint) VALUES('w','main','main','git','git','file:///main','1','1','file','file:///main','sha256:fixture');").unwrap();
let store = SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(conn)),
db_executor: crate::db_executor::DbExecutor::new(
crate::db_executor::DbExecutorLimits::default(),
)
.unwrap(),
};
seed_manual_start_ticket(&store, "tracking", "open");
store.with_conn(|conn| {
// The old principal and normal assignment triggers are genuine retained
// data. Current assignment APIs intentionally cannot create this identity.
conn.execute_batch("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','tracking','delegation','orchestrator','workspace_agent','workspace-orchestrator','legacy-owner','2');
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','tracking','delegation','orchestrator','workspace_agent','workspace-orchestrator','2');
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id) VALUES('w','tracking','orchestrator','legacy-assigned-event','assigned','delegation','legacy-owner','2','legacy-assign');")?;
Ok(())
}).unwrap();
(directory, store)
}
// Seed accepted PRE-upgrade notification evidence as historical SQL data. The
// current router must never authorize workspace-orchestrator to recreate it.
fn implementation_seed_legacy_result_delivery(conn: &Connection, index: usize) -> Result<()> {
let event = format!("implementation-result:legacy-result-{index}");
let delivery_id = format!("legacy-result-delivery-{index}");
let input = crate::store::TicketAssignmentDelivery {
ticket_id: "tracking".into(),
assignment_id: "delegation".into(),
input_event_id: event.clone(),
delivery_id: delivery_id.clone(),
request_id: delivery_id.clone(),
waiting_reason: None,
next_action: None,
};
let source = serde_json::json!({"actor":"backend:assignment-delivery", "runtime_id":"r", "worker_id":"worker", "agent_key":"workspace-orchestrator", "authentication_binding_id":"tracking-binding"}).to_string();
conn.execute("INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) SELECT sequence,'w',?1,'deliver','result','result' FROM notification_sources WHERE input_event_id=?2 AND role='orchestrator'", params![delivery_id,event])?;
conn.execute(
"INSERT INTO ticket_assignment_deliveries VALUES('w','delegation',?1,?2,?3,?4)",
params![
event,
delivery_id,
serde_json::to_string(&input).unwrap(),
source
],
)?;
conn.execute_batch(
"DROP TRIGGER notification_mr_event;
DROP TRIGGER notification_mr_source;
DROP TRIGGER notification_implementation_result;
DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results;
DROP TABLE ticket_implementation_delegations;
DROP TABLE merge_request_source_observations;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
DROP TRIGGER backend_job_input_retirement;
DROP TRIGGER backend_job_identity_immutable;
ALTER TABLE backend_jobs DROP COLUMN input_disposition;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=104;
DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases;
UPDATE __yoi_schema_migrations SET version=100 WHERE version=101;
DROP TABLE user_activations;
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;",
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w',?1,'Retained legacy report notification')", [&delivery_id])?;
conn.execute(
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w',?1,?2)",
params![delivery_id, source],
)?;
assert_eq!(current_schema_version(conn)?, 96);
assert_eq!(conn.query_row(
"SELECT count(*) FROM sqlite_schema WHERE name LIKE 'subject_delet%' OR sql LIKE '%subject_deletion%'",
[], |row| row.get::<_, i64>(0),
)?, 0);
for table in [
"notification_sources",
"notification_decisions",
"notification_requests",
"notification_acknowledgements",
"notification_failures",
"notification_job_targets",
"notification_job_legacy_receipts",
"notification_source_preparation_failures",
"backend_jobs",
"backend_job_attempts",
"worker_create_reservations",
] {
assert_eq!(
state_contract_schema_signature(conn, table),
state_contract_schema_signature(&retained, table),
"schema96 {table}"
);
}
conn.execute("INSERT INTO notification_assignment_targets VALUES('w',?1,'r','worker','workspace-orchestrator','tracking-binding')", [&delivery_id])?;
conn.execute(
"INSERT INTO notification_requests VALUES('w',?1,'Retained legacy report notification',?2)",
params![delivery_id, source],
)?;
conn.execute(
"INSERT INTO notification_acknowledgements VALUES('w',?1,?2)",
params![delivery_id, source],
)?;
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w',?1,'runtime_rejected','4')", [&delivery_id])?;
Ok(())
}
#[tokio::test]
async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_notification_evidence() {
let (dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "delegation", "assign");
async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_notification_evidence()
{
let (dir, store) = implementation_legacy96_fixture();
let worker = routing_worker_assign(&store, "implementation").worker;
// Reconstruct schema97 from a fresh current-schema fixture, including its historical capture.
store.with_conn(|conn| {
reconstruct_implementation_schema96(conn)?;
migrate_business_notifications_v96_to_v97(conn)?;
assert_eq!(current_schema_version(conn)?, 97);
Ok(())
}).unwrap();
// Apply the real forward 96 -> 97 migration before seeding schema97 reports.
store
.with_conn(|conn| {
migrate_business_notifications_v96_to_v97(conn)?;
assert_eq!(current_schema_version(conn)?, 97);
Ok(())
})
.unwrap();
let input = implementation_result_input();
let mut second = input.clone();
second.operation_key = "legacy-second-operation".into();
second.body = "A second retained report".into();
let requests = [input.clone(), second];
let raw_requests = requests.iter().map(|request| {
let mut legacy = serde_json::to_value(request).unwrap();
legacy["assignment_id"] = serde_json::json!("implementation");
legacy["orchestrator_assignment_id"] = serde_json::json!("delegation");
format!(" \n{}\n ", serde_json::to_string_pretty(&legacy).unwrap())
}).collect::<Vec<_>>();
let raw_requests = requests
.iter()
.map(|request| {
let mut legacy = serde_json::to_value(request).unwrap();
legacy["assignment_id"] = serde_json::json!("implementation");
legacy["orchestrator_assignment_id"] = serde_json::json!("delegation");
format!(" \n{}\n ", serde_json::to_string_pretty(&legacy).unwrap())
})
.collect::<Vec<_>>();
store.with_conn(|conn| {
for (index, request) in requests.iter().enumerate() {
conn.execute("INSERT INTO ticket_implementation_results(workspace_id,ticket_id,operation_key,result_id,assignment_id,orchestrator_assignment_id,runtime_id,worker_id,request_json,created_at)
@@ -500,23 +626,16 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
}
Ok(())
}).unwrap();
// Populate persisted request, target, ACK and failure evidence, not just source rows.
let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap();
for index in 0..requests.len() {
let input_event = format!("implementation-result:legacy-result-{index}");
assert!(pending.iter().any(|p| p.input_event_id == input_event));
let reserved = store.reserve_ticket_assignment_delivery("w", "delegation", &input_event, &tracking_store_source()).unwrap();
store.prepare_ticket_assignment_delivery_content("w", &reserved.delivery_id, "Retained legacy report notification", &tracking_store_source()).unwrap();
store.ack_ticket_assignment_delivery("w", &reserved.delivery_id, &tracking_store_source()).unwrap();
store.with_conn(|conn| {
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w',?1,'legacy-failure','4')", [&reserved.delivery_id])?;
// Persist real historical row shapes without calling current Subject-only
// send/reserve APIs as though the legacy identity were live authority.
store
.with_conn(|conn| {
for index in 0..requests.len() {
implementation_seed_legacy_result_delivery(conn, index)?;
}
Ok(())
}).unwrap();
}
routing_ack_worker_pending(&store, &TicketWorkerNotificationTarget {
worker: worker.clone(),
authentication_binding_id: "tracking-binding".into(),
});
})
.unwrap();
let (results, business, evidence) = store.with_conn(|conn| {
Ok((
fixture_query_rows(conn, "SELECT workspace_id,ticket_id,operation_key,result_id,runtime_id,worker_id,request_json,created_at FROM ticket_implementation_results ORDER BY rowid"),
@@ -524,7 +643,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
implementation_result_notification_evidence(conn),
))
}).unwrap();
assert!(evidence.iter().all(|rows| !rows.is_empty()), "fixture must retain nonempty source, decision, request, target, ACK and failure evidence");
assert!(
evidence.iter().all(|rows| !rows.is_empty()),
"fixture must retain nonempty source, decision, request, target, ACK and failure evidence"
);
store.with_conn(|conn| {
// The migration must roll back its trigger drops and leave every byte replayable.
conn.execute_batch("CREATE TABLE ticket_implementation_results_v98(collision INTEGER);")?;
@@ -557,52 +679,114 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
for (index, request) in requests.iter().enumerate() {
let expected = server_api::TicketImplementationResultReceipt {
result_id: format!("legacy-result-{index}"), ticket_id: "tracking".into(),
request: request.clone(), created_at: "4".into(),
result_id: format!("legacy-result-{index}"),
ticket_id: "tracking".into(),
request: request.clone(),
created_at: "4".into(),
};
assert_eq!(reopened.implementation_result("w", "tracking", &expected.result_id).unwrap(), expected);
assert_eq!(reopened.record_implementation_result("w", "tracking", request, &worker, "tracking-binding").unwrap(), expected);
assert_eq!(
reopened
.implementation_result("w", "tracking", &expected.result_id)
.unwrap(),
expected
);
assert_eq!(
reopened
.record_implementation_result("w", "tracking", request, &worker, "tracking-binding")
.unwrap(),
expected
);
}
reopened.with_conn(|conn| {
assert_eq!(fixture_query_rows(conn, "SELECT * FROM ticket_implementation_results ORDER BY rowid"), results);
assert_eq!(implementation_result_notification_evidence(conn), evidence, "reading and replaying legacy JSON must never recapture notifications");
Ok(())
}).unwrap();
reopened
.with_conn(|conn| {
assert_eq!(
fixture_query_rows(
conn,
"SELECT * FROM ticket_implementation_results ORDER BY rowid"
),
results
);
assert_eq!(
implementation_result_notification_evidence(conn),
evidence,
"reading and replaying legacy JSON must never recapture notifications"
);
Ok(())
})
.unwrap();
tracking_store_assign(&reopened, "new-delegation", "assign-new");
routing_worker_assign(&reopened, "new-implementation");
reopened.with_conn(|conn| {
assert_eq!(stored_fixture_rows(conn, "ticket_implementation_delegations"), business[9], "historical delegation evidence must not grow");
for index in 0..requests.len() {
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:legacy-result-{index}")], |r| r.get::<_, String>(0))?, "delegation");
}
Ok(())
}).unwrap();
reopened
.with_conn(|conn| {
assert_eq!(
stored_fixture_rows(conn, "ticket_implementation_delegations"),
business[9],
"historical delegation evidence must not grow"
);
for index in 0..requests.len() {
assert_eq!(
conn.query_row(
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
[format!("implementation-result:legacy-result-{index}")],
|r| r.get::<_, String>(0)
)?,
"delegation"
);
}
Ok(())
})
.unwrap();
for request in &requests {
reopened.record_implementation_result("w", "tracking", request, &worker, "tracking-binding").unwrap();
reopened
.record_implementation_result("w", "tracking", request, &worker, "tracking-binding")
.unwrap();
}
let mut fresh = input;
fresh.operation_key = "new-schema98-report".into();
let receipt = reopened.record_implementation_result("w", "tracking", &fresh, &worker, "tracking-binding").unwrap();
reopened.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:{}", receipt.result_id)], |r| r.get::<_, String>(0))?, "new-delegation");
for index in 0..requests.len() {
assert_eq!(conn.query_row("SELECT request_json FROM ticket_implementation_results WHERE result_id=?1", [format!("legacy-result-{index}")], |r| r.get::<_, String>(0))?, raw_requests[index]);
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:legacy-result-{index}")], |r| r.get::<_, i64>(0))?, 1);
}
Ok(())
}).unwrap();
let receipt = reopened
.record_implementation_result("w", "tracking", &fresh, &worker, "tracking-binding")
.unwrap();
reopened
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
[format!("implementation-result:{}", receipt.result_id)],
|r| r.get::<_, String>(0)
)?,
"new-delegation"
);
for index in 0..requests.len() {
assert_eq!(
conn.query_row(
"SELECT request_json FROM ticket_implementation_results WHERE result_id=?1",
[format!("legacy-result-{index}")],
|r| r.get::<_, String>(0)
)?,
raw_requests[index]
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM notification_sources WHERE input_event_id=?1",
[format!("implementation-result:legacy-result-{index}")],
|r| r.get::<_, i64>(0)
)?,
1
);
}
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknown_legacy_results() {
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "delegation", "assign");
let (_dir, store) = implementation_legacy96_fixture();
routing_worker_assign(&store, "implementation");
routing_worker_assign(&store, "known-implementation");
store.with_conn(|conn| {
conn.execute("INSERT INTO ticket_assignment_operation_fences VALUES('w','operation-known-implementation','delegation','r','worker','workspace-orchestrator','tracking-binding',?1)",[serde_json::to_string(&tracking_store_source()).unwrap()])?;
// Reconstruct the prior composition before its new business capture existed.
reconstruct_implementation_schema96(conn)?;
let legacy_source = serde_json::json!({"actor":"authenticated-worker:r/worker", "runtime_id":"r", "worker_id":"worker", "agent_key":"workspace-orchestrator", "authentication_binding_id":"tracking-binding"});
conn.execute("INSERT INTO ticket_assignment_operation_fences VALUES('w','operation-known-implementation','delegation','r','worker','workspace-orchestrator','tracking-binding',?1)",[legacy_source.to_string()])?;
conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3');
INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3');
INSERT INTO merge_request_thread_events VALUES('w','historical-mr','approved-event',1,'review','{\"subject_ref\":\"source-a\",\"decision\":\"approve\"}',NULL,'3');
@@ -629,8 +813,14 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
assert!(
inputs
.iter()
.any(|i| i.input_event_id == "mr-event:historical-mr:approved-event")
.all(|i| i.input_event_id != "mr-event:historical-mr:approved-event"),
"retained legacy capture must not become current Subject delivery authority"
);
store.with_conn(|conn| {
let decision: (String, String) = conn.query_row("SELECT d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id='mr-event:historical-mr:approved-event' AND s.role='orchestrator'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
assert_eq!(decision, ("suppress".into(),"unsupported_recipient".into()));
Ok(())
}).unwrap();
for _ in 0..2 {
store
.observe_merge_request_source("w", "main", "refs/heads/work", "source-b")
@@ -681,7 +871,14 @@ async fn implementation_result_notification_failure_rolls_back_and_concurrent_re
0
);
assert_eq!(implementation_result_business_state(conn), before);
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 0);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM notification_sources WHERE kind='implementation_result'",
[],
|r| r.get::<_, i64>(0)
)?,
0
);
conn.execute_batch("DROP TRIGGER reject_result_notification;")?;
Ok(())
})
@@ -742,20 +939,37 @@ async fn implementation_result_notification_failure_rolls_back_and_concurrent_re
#[tokio::test]
async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
for historical in [false, true] {
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "delegation", "assign");
let (_dir, store) = if historical {
implementation_legacy96_fixture()
} else {
let fixture = tracking_store_fixture().await;
tracking_store_assign(&fixture.1, "delegation", "assign");
fixture
};
routing_worker_assign(&store, "implementation");
let events = [
("review_requested", Some("requested_by"), Some("replacement"), Some("worker")),
(
"review_requested",
Some("requested_by"),
Some("replacement"),
Some("worker"),
),
("review", Some("reviewer"), Some("reviewer-child"), None),
("review_revoked", Some("revoked_by"), Some("replacement"), Some("worker")),
("merge", Some("merged_by"), Some("worker"), Some("orchestrator")),
(
"review_revoked",
Some("revoked_by"),
Some("replacement"),
Some("worker"),
),
(
"merge",
Some("merged_by"),
Some("worker"),
Some("orchestrator"),
),
("review_cancelled", None, None, None),
];
store.with_conn(|conn| {
if historical {
reconstruct_implementation_schema96(conn)?;
}
conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3');
INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?;
for (index, (kind, field, worker, _)) in events.iter().enumerate() {
@@ -777,7 +991,10 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
"SELECT s.actor,d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1 AND s.role=?2",
params![format!("mr-event:actor-mr:{kind}"),role],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
assert_eq!(actor,worker.map(|w|format!("worker:r/{w}")),"{kind}, historical={historical}");
if suppressed_role == Some(role) {
if historical && role == "orchestrator" {
assert_eq!((disposition.as_str(),reason.as_str()),("suppress","unsupported_recipient"),
"legacy capture preserves actors but never restores Subject routing authority");
} else if suppressed_role == Some(role) {
assert_eq!((disposition.as_str(),reason.as_str()),("suppress","self_notification"),"{kind}, historical={historical}");
} else {
assert_eq!((disposition.as_str(),reason.as_str()),("deliver","attention"),"{kind}, {role}, historical={historical}");
@@ -792,9 +1009,9 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
#[tokio::test]
async fn implementation_result_accepts_only_observations_with_matching_mr_ticket_workspace() {
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store,"delegation","assign");
let worker = routing_worker_assign(&store,"implementation").worker;
seed_manual_start_ticket(&store,"unrelated","open");
tracking_store_assign(&store, "delegation", "assign");
let worker = routing_worker_assign(&store, "implementation").worker;
seed_manual_start_ticket(&store, "unrelated", "open");
store.with_conn(|conn| {
conn.execute_batch("INSERT INTO merge_requests VALUES('w','observed-mr','main','open','work','main','3','3');
INSERT INTO merge_request_ticket_relations VALUES('w','observed-mr','tracking','implements','3');
@@ -802,23 +1019,56 @@ async fn implementation_result_accepts_only_observations_with_matching_mr_ticket
INSERT INTO merge_request_ticket_relations VALUES('w','other-mr','unrelated','implements','3');")?;
Ok(())
}).unwrap();
store.observe_merge_request_source("w","main","work","source-b").unwrap();
store.observe_merge_request_source("w","main","other","other-source").unwrap();
for (workspace,mr,subject,accepted) in [
("w","observed-mr","forged",false),
("w","observed-mr","other-source",false),
("w","other-mr","other-source",false),
("other","observed-mr","source-b",false),
("w","observed-mr","source-b",true),
store
.observe_merge_request_source("w", "main", "work", "source-b")
.unwrap();
store
.observe_merge_request_source("w", "main", "other", "other-source")
.unwrap();
for (workspace, mr, subject, accepted) in [
("w", "observed-mr", "forged", false),
("w", "observed-mr", "other-source", false),
("w", "other-mr", "other-source", false),
("other", "observed-mr", "source-b", false),
("w", "observed-mr", "source-b", true),
] {
let mut input = implementation_result_input();
input.merge_request = Some(server_api::MergeRequestReviewSubject {merge_request_id:mr.into(),subject_ref:subject.into()});
let result = store.record_implementation_result(workspace,"tracking",&input,&worker,"tracking-binding");
assert_eq!(result.is_ok(),accepted,"{workspace}/{mr}/{subject}: {result:?}");
input.merge_request = Some(server_api::MergeRequestReviewSubject {
merge_request_id: mr.into(),
subject_ref: subject.into(),
});
let result = store.record_implementation_result(
workspace,
"tracking",
&input,
&worker,
"tracking-binding",
);
assert_eq!(
result.is_ok(),
accepted,
"{workspace}/{mr}/{subject}: {result:?}"
);
}
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT count(*) FROM merge_request_thread_events",[],|r|r.get::<_,i64>(0))?,0);
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_implementation_results",[],|r|r.get::<_,i64>(0))?,1);
Ok(())
}).unwrap();
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM merge_request_thread_events",
[],
|r| r.get::<_, i64>(0)
)?,
0
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM ticket_implementation_results",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
Ok(())
})
.unwrap();
}
@@ -154,6 +154,23 @@ fn fixed_worker_target(
})
}
fn authorize_worker_attention(
api: &WorkspaceApi,
input: &TicketWorkerNotification,
) -> ApiResult<()> {
subject_authorization::authorize(
api,
&input.worker,
&server_api::authorization::OperationAuthorizationRequest {
operation: "ticket.read".into(),
resource: server_api::authorization::PermissionResource::Ticket {
workspace_id: api.workspace_id().into(),
ticket_id: input.ticket_id.clone(),
},
},
)
}
pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
let Some(_permit) = api.notification_delivery_lifecycle.begin() else {
return;
@@ -182,6 +199,10 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
continue;
}
};
if authorize_worker_attention(api, &input).is_err() {
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
continue;
}
let resource_key = match api.store.resource_key(
&api.config.workspace_id,
WorkspaceResourceKind::Ticket,
@@ -199,13 +220,17 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
"Ticket {resource_key} has committed activity (input {}). Reread the current Ticket and assignment before acting. This notification does not grant or extend execution authority.",
input.input_event_id,
);
let content = match api.store.prepare_ticket_worker_notification_content(
if authorize_worker_attention(api, &input).is_err() {
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
continue;
}
match api.store.prepare_ticket_worker_notification_content(
&api.config.workspace_id,
&input.delivery_id,
&content,
&target,
) {
Ok(content) => content,
Ok(_) => (),
Err(error) => {
let code = match error {
Error::TicketAssignmentConflict(_) => {
@@ -217,6 +242,22 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
continue;
}
};
let content = match api.store.admit_ticket_worker_notification_send(
api.workspace_id(),
&input.delivery_id,
&target,
) {
Ok(Some(content)) => content,
Ok(None) => continue,
Err(_) => {
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
continue;
}
};
if authorize_worker_attention(api, &input).is_err() {
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
continue;
}
let result = api.runtime.send_input(
&input.worker,
WorkerInputRequest {
@@ -274,26 +315,35 @@ pub(super) fn recover_ticket_notifications(api: &WorkspaceApi) {
};
recover_job_notifications(api);
dispatch_ticket_worker_notifications(api);
if let Some(orchestrator) = find_workspace_orchestrator(api) {
dispatch_orchestrator_assignment_attention(api, &orchestrator.worker);
} else {
// Still reconcile committed sources and explain target absence even when
// there is no Runtime Worker. Do not start or restore one as a side effect.
match api
.store
.list_pending_ticket_assignment_deliveries(&api.config.workspace_id, 20)
{
Ok(inputs) => {
for input in inputs {
record_notification_failure(
api,
&assignment_delivery_key(&input),
NotificationFailureCode::TargetUnavailable,
);
}
}
Err(error) => tracing::warn!(%error, "assignment notification scan failed"),
// Resolve each current assignment independently. A missing body is not an implicit restore.
let inputs = match api
.store
.list_pending_ticket_assignment_deliveries(api.workspace_id(), 100)
{
Ok(inputs) => inputs,
Err(error) => {
tracing::warn!(%error, "assignment notification scan failed");
return;
}
};
let mut recipients = std::collections::BTreeSet::new();
for input in inputs {
match assigned_subject_body(api, &input.ticket_id, &input.assignment_id) {
Ok(Some(worker)) => {
recipients.insert((worker.runtime_id, worker.worker_id));
}
_ => record_notification_failure(
api,
&assignment_delivery_key(&input),
NotificationFailureCode::TargetUnavailable,
),
}
}
for (runtime_id, worker_id) in recipients {
dispatch_orchestrator_assignment_attention(
api,
&RuntimeWorkerRef::new(runtime_id, worker_id),
);
}
}
@@ -45,7 +45,13 @@ async fn notification_diagnostics_preserves_unknown_acceptance_and_marks_replace
assert_eq!(read().stage, S::AcceptanceUnknown);
assert!(read().acceptance_outcome_unknown);
assert_eq!(read().last_failure_code.as_deref(), Some("target_stopped"));
store.with_conn(|conn|{conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'",[])?;Ok(())}).unwrap();
store.with_conn(|conn| {
conn.execute_batch("UPDATE worker_create_reservations SET state='removed' WHERE workspace_id='w' AND singleton_key='subjektiv:tracking-subject';
UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w' AND singleton_key='subjektiv:tracking-subject';
INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at,singleton_key,singleton_generation)
VALUES('w','replacement-body','replacement','r','replacement-body','created','2','2','subjektiv:tracking-subject',2);")?;
Ok(())
}).unwrap();
let replacement = TicketAssignmentTrackingSourceContext {
worker_id: "replacement".into(),
..source
@@ -182,8 +188,8 @@ async fn notification_diagnostics_detects_unconnected_origin_and_retains_history
#[tokio::test]
async fn notification_diagnostics_is_readonly_paginated_and_workspace_scoped() {
use server_api::{NotificationDiagnosticStage as S, NotificationDiagnosticsQuery as Q};
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let (_dir, store) = delivery_tracking_fixture().await;
delivery_tracking_assign(&store, "a", "assign-a");
delivery_store_answer(&store, "answer-a");
let before = store
.with_conn(|conn| {
@@ -377,8 +383,18 @@ async fn notification_source_metadata_resolves_hashed_and_legacy_identities_with
use server_api::authorization::PermissionResource;
let (_dir, store) = tracking_store_fixture().await;
let (_, source) = job_notice_fixture(&store, "metadata-only", false);
let job = store.get_backend_job("w", "metadata-only").unwrap().unwrap();
let retired = store.reclaim_backend_job_input("w", "metadata-only", &job.input_digest, crate::backend_job::JobInputRetirementReason::ExplicitErasure).unwrap();
let job = store
.get_backend_job("w", "metadata-only")
.unwrap()
.unwrap();
let retired = store
.reclaim_backend_job_input(
"w",
"metadata-only",
&job.input_digest,
crate::backend_job::JobInputRetirementReason::ExplicitErasure,
)
.unwrap();
assert!(retired.load_executable_request().is_err());
store.with_conn(|conn| {
// Retired protected input cannot execute, but notification ownership
@@ -402,7 +418,13 @@ async fn notification_source_metadata_resolves_hashed_and_legacy_identities_with
);
let other = store.notification_source_resource("other", event).unwrap();
if event == "mr-event:legacy-job:e" {
assert_eq!(other, Some(PermissionResource::MergeRequest { workspace_id: "other".into(), merge_request_id: "legacy-job".into() }));
assert_eq!(
other,
Some(PermissionResource::MergeRequest {
workspace_id: "other".into(),
merge_request_id: "legacy-job".into()
})
);
} else {
assert!(other.is_none());
}
@@ -179,8 +179,11 @@ fn policy(
return ("suppress", R::NoRecipient);
}
let supported = if role == "orchestrator" {
facts.principal_kind.as_deref() == Some("workspace_agent")
&& facts.principal_id.as_deref() == Some("workspace-orchestrator")
facts.principal_kind.as_deref() == Some("subject")
&& facts
.principal_id
.as_deref()
.is_some_and(|id| !id.trim().is_empty())
} else {
role == "worker" && facts.principal_kind.as_deref() == Some("worker")
};
@@ -231,6 +234,56 @@ fn policy(
const FACTS_SQL: &str = "json_object('principal_kind',a.principal_kind,'principal_id',a.principal_id,'runtime_id',COALESCE(a.runtime_id,(SELECT runtime_id FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key=a.principal_id AND a.principal_kind='workspace_agent')),'worker_id',COALESCE(a.worker_id,(SELECT worker_id FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key=a.principal_id AND a.principal_kind='workspace_agent')),'status',json_extract(a.tracking_json,'$.status'),'case_state',t.workflow_state,'released',json(CASE WHEN EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id) THEN 'true' ELSE 'false' END))";
// FACTS_SQL above is retained verbatim for applied schema95/97/98 migrations.
// Current capture resolves the existing Subject singleton owner, never a second registry.
fn subject_facts_sql() -> String {
let prefix = crate::subjektiv::SUBJECT_WORKER_SINGLETON_PREFIX.replace('\'', "''");
let owner = |column: &str| {
format!(
"CASE WHEN a.principal_kind='subject' THEN (SELECT {column} FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key='{prefix}' || a.principal_id) ELSE a.{column} END"
)
};
format!(
"json_object('principal_kind',a.principal_kind,'principal_id',a.principal_id,'runtime_id',{},'worker_id',{},'status',json_extract(a.tracking_json,'$.status'),'case_state',t.workflow_state,'released',json(CASE WHEN EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id) THEN 'true' ELSE 'false' END))",
owner("runtime_id"),
owner("worker_id")
)
}
/// Additive upgrade of future capture only. Old recipient snapshots and receipts remain
/// evidence; an unresolved legacy principal is not mapped to an arbitrary Subject.
/// The caller owns the Workspace schema transaction and runs this after assignment migration.
pub(super) fn install_subject_recipients(conn: &Connection) -> Result<()> {
let facts = subject_facts_sql();
for trigger in [
"notification_ticket_event",
"notification_assignment_history",
"notification_assignment_change",
"notification_assignment_release",
"notification_content_input",
"notification_unassigned_content",
"notification_mr_event",
"notification_mr_source",
"notification_implementation_result",
] {
let sql: String = conn.query_row(
"SELECT sql FROM sqlite_schema WHERE type='trigger' AND name=?1",
[trigger],
|row| row.get(0),
)?;
if !sql.contains(FACTS_SQL) {
return Err(Error::Store(format!(
"Notification capture {trigger} does not match the prior schema"
)));
}
conn.execute_batch(&format!(
"DROP TRIGGER {trigger}; {}",
sql.replace(FACTS_SQL, &facts)
))?;
}
Ok(())
}
fn capture(input: &str, kind: &str, actor: &str, event_index: &str) -> String {
format!("INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,actor,event_index,facts_json)
SELECT NEW.workspace_id,NEW.ticket_id,{input},roles.role,COALESCE(a.assignment_id,''),{kind},{actor},{event_index},{FACTS_SQL}
@@ -694,7 +747,8 @@ pub(super) fn require_current(
assignment: &str,
role: &str,
) -> Result<()> {
let facts:Option<String>=conn.query_row(&format!("SELECT {FACTS_SQL} FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.assignment_id=?3 AND c.role=?4"),params![workspace,ticket,assignment,role],|r|r.get(0)).optional()?;
let facts_sql = subject_facts_sql();
let facts:Option<String>=conn.query_row(&format!("SELECT {facts_sql} FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.assignment_id=?3 AND c.role=?4"),params![workspace,ticket,assignment,role],|r|r.get(0)).optional()?;
if let Some(facts) = facts {
let facts: Facts = decode(&facts)?;
if policy(
@@ -819,6 +873,13 @@ fn worker_guard(
}
Ok(())
}
fn worker_acknowledged(conn: &Connection, workspace: &str, id: &str) -> Result<bool> {
Ok(conn.query_row(
"SELECT EXISTS(SELECT 1 FROM notification_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2)",
params![workspace, id], |row| row.get(0),
)?)
}
pub(super) fn prepare_worker(
store: &SqliteWorkspaceStore,
workspace: &str,
@@ -833,6 +894,7 @@ pub(super) fn prepare_worker(
}
store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
worker_guard(&tx,workspace,id,target)?;
if worker_acknowledged(&tx,workspace,id)? { return Err(Error::TicketAssignmentConflict("Worker notification already has Runtime acceptance; new send admission is closed".into())); }
let saved:Option<(String,String)>=tx.query_row("SELECT content,target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
let result=if let Some((saved,binding))=saved {
if binding!=json(target)? { return Err(Error::TicketAssignmentConflict("Prepared notification target binding changed".into())); }
@@ -841,6 +903,31 @@ pub(super) fn prepare_worker(
tx.commit()?;Ok(result)
})
}
/// Authorize a new Runtime send, not a historical receipt read. The Host must
/// additionally check live recipient policy before supply and immediately before
/// I/O. An ACK closes admission without erasing the pinned request or receipt.
pub(super) fn admit_worker_send(
store: &SqliteWorkspaceStore,
workspace: &str,
id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<Option<String>> {
store.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
worker_guard(&tx, workspace, id, target)?;
let (content, saved_target): (String, String) = tx.query_row(
"SELECT content,target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",
params![workspace, id], |row| Ok((row.get(0)?, row.get(1)?)),
).optional()?.ok_or_else(|| Error::TicketAssignmentConflict("Worker notification send requires a persisted request".into()))?;
if saved_target != json(target)? {
return Err(Error::TicketAssignmentConflict("Worker notification send target differs from prepared request".into()));
}
let content = if worker_acknowledged(&tx, workspace, id)? { None } else { Some(content) };
tx.commit()?;
Ok(content)
})
}
pub(super) fn ack_worker(
store: &SqliteWorkspaceStore,
workspace: &str,
@@ -866,8 +953,8 @@ mod tests {
#[test]
fn notification_routing_policy_distinguishes_requests_attention_results_and_suppression() {
let mut facts = Facts {
principal_kind: Some("workspace_agent".into()),
principal_id: Some("workspace-orchestrator".into()),
principal_kind: Some("subject".into()),
principal_id: Some("subject-a".into()),
runtime_id: None,
worker_id: None,
status: Some("waiting".into()),
@@ -49,7 +49,7 @@ fn routing_ack_worker_pending(
#[tokio::test]
async fn notification_routing_diagnostics_preview_unrouted_requests_readonly_beyond_global_batch() {
let (_directory, store) = tracking_store_fixture().await;
let (_directory, store) = delivery_tracking_fixture().await;
seed_manual_start_ticket(&store, "other-ticket", "open");
store.with_conn_mut(|conn| {
let tx = conn.transaction()?;
@@ -59,7 +59,7 @@ async fn notification_routing_diagnostics_preview_unrouted_requests_readonly_bey
tx.commit()?;
Ok(())
}).unwrap();
tracking_store_assign(&store, "a", "assign-a");
delivery_tracking_assign(&store, "a", "assign-a");
delivery_store_answer(&store, "preview-answer");
// An answer without the assignment fence must be suppressed by the same Rust policy.
delivery_store_external_event(&store, 1, "assignment_answer", None);
@@ -183,9 +183,9 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
"worker-replaced",
] {
for prepare in [false, true] {
let (_directory, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let source = tracking_store_source();
let (_directory, store) = delivery_tracking_fixture().await;
delivery_tracking_assign(&store, "a", "assign-a");
let source = delivery_tracking_source();
let delivery = delivery_store_reserve_initial(&store, &source);
if prepare {
store
@@ -200,7 +200,7 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
match change {
"done" => { store.update_ticket_assignment_tracking("w", "tracking", &tracking_store_input("a", "event-assign-a", "done-a"), &source).unwrap(); }
"release" => { store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator, "a", "clear-a", "clear-a", "human", "now", None).unwrap(); }
"reassign" => { tracking_store_assign(&store, "b", "assign-b"); }
"reassign" => { delivery_tracking_assign(&store, "b", "assign-b"); }
_ => store.with_conn(|conn| {
match change {
"revoked" => conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'", [])?,
@@ -215,7 +215,7 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
match field {
"runtime" => forged.runtime_id = "forged".into(),
"worker" => forged.worker_id = "replacement".into(),
"agent" => forged.agent_key = "forged".into(),
"agent" => forged.subject_id = "forged".into(),
_ => forged.authentication_binding_id = "replacement-binding".into(),
}
assert!(
@@ -280,9 +280,9 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
#[tokio::test]
async fn notification_routing_replacement_target_requires_explicit_authorized_reserve_or_prepare() {
for prepare in [false, true] {
let (_directory, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let source = tracking_store_source();
let (_directory, store) = delivery_tracking_fixture().await;
delivery_tracking_assign(&store, "a", "assign-a");
let source = delivery_tracking_source();
let delivery = delivery_store_reserve_initial(&store, &source);
store
.prepare_ticket_assignment_delivery_content(
@@ -292,10 +292,7 @@ async fn notification_routing_replacement_target_requires_explicit_authorized_re
&source,
)
.unwrap();
store.with_conn(|conn| {
conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'", [])?;
Ok(())
}).unwrap();
delivery_tracking_replace_body(&store);
let replacement = TicketAssignmentTrackingSourceContext {
worker_id: "replacement".into(),
..source.clone()
@@ -376,7 +373,7 @@ async fn notification_routing_replacement_target_requires_explicit_authorized_re
#[tokio::test]
async fn notification_routing_commit_without_callback_recovers_and_rollback_has_no_source() {
let (directory, store) = tracking_store_fixture().await;
let (directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
store.with_conn_mut(|conn| {
@@ -408,7 +405,7 @@ async fn notification_routing_commit_without_callback_recovers_and_rollback_has_
#[tokio::test]
async fn notification_routing_parallel_scan_and_render_reuse_request_after_response_loss() {
let (directory, store) = tracking_store_fixture().await;
let (directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
delivery_store_external_event(&store, 0, "comment", None);
@@ -497,7 +494,7 @@ async fn notification_routing_parallel_scan_and_render_reuse_request_after_respo
#[tokio::test]
async fn notification_routing_assignment_change_between_commit_and_scan_never_transfers_input() {
let (_directory, store) = tracking_store_fixture().await;
let (_directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
delivery_store_external_event(&store, 0, "comment", None);
@@ -517,7 +514,7 @@ async fn notification_routing_assignment_change_between_commit_and_scan_never_tr
#[tokio::test]
async fn notification_routing_ack_retains_acceptance_after_responsibility_changes() {
let (_directory, store) = tracking_store_fixture().await;
let (_directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
delivery_store_external_event(&store, 0, "comment", None);
@@ -557,7 +554,7 @@ async fn notification_routing_ack_retains_acceptance_after_responsibility_change
#[tokio::test]
async fn notification_routing_editor_is_not_original_author_and_unknown_events_are_deferred() {
let (directory, store) = tracking_store_fixture().await;
let (directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
use ticket::TicketBackend;
@@ -625,7 +622,7 @@ async fn notification_routing_editor_is_not_original_author_and_unknown_events_a
#[tokio::test]
async fn notification_routing_failures_do_not_starve_other_inputs_or_replace_requests() {
let (_directory, store) = tracking_store_fixture().await;
let (_directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
delivery_store_external_event(&store, 0, "comment", None);
@@ -674,7 +671,7 @@ async fn notification_routing_failures_do_not_starve_other_inputs_or_replace_req
#[tokio::test]
async fn notification_routing_target_binding_and_workspace_are_rechecked_before_prepare() {
let (_directory, store) = tracking_store_fixture().await;
let (_directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
let input = store
.list_pending_ticket_worker_notifications("w", 100)
@@ -733,23 +730,27 @@ fn notification_routing_migration_preserves_receipts_does_not_replay_comments_an
waiting_reason: None,
next_action: None,
};
// Retained94 stores an agent-key context, not today's Subject context.
let reserved_source_json = r#"{"actor":"authenticated-worker:r/worker","runtime_id":"r","worker_id":"worker","agent_key":"workspace-orchestrator","authentication_binding_id":"tracking-binding"}"#;
let acknowledged_source_json = r#"{"actor":"authenticated-worker:r/worker","runtime_id":"r","worker_id":"old-replacement","agent_key":"workspace-orchestrator","authentication_binding_id":"tracking-binding"}"#;
conn.execute(
"INSERT INTO ticket_assignment_deliveries VALUES('w','a-pending',?1,'preserved-id',?2,?3)",
params![
payload.input_event_id,
serde_json::to_string(&payload).unwrap(),
serde_json::to_string(&tracking_store_source()).unwrap()
reserved_source_json
],
)
.unwrap();
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w','preserved-id','immutable request')",[]).unwrap();
let acknowledged_target = TicketAssignmentTrackingSourceContext {
worker_id: "old-replacement".into(),
..tracking_store_source()
subject_id: "workspace-orchestrator".into(),
..delivery_tracking_source()
};
conn.execute(
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','preserved-id',?1)",
[serde_json::to_string(&acknowledged_target).unwrap()],
[acknowledged_source_json],
)
.unwrap();
let requests_before = stored_fixture_rows(&conn, "ticket_assignment_delivery_requests");
@@ -806,9 +807,18 @@ fn notification_routing_migration_preserves_receipts_does_not_replay_comments_an
!pending.iter().any(|p| p.assignment_id == "a-pending"),
"saved ACK remains authoritative even with non-derived legacy delivery ID"
);
store
.ack_ticket_assignment_delivery("w", "preserved-id", &acknowledged_target)
.unwrap();
// A new Subject-shaped context must not reinterpret the immutable agent ACK.
assert!(matches!(
store.ack_ticket_assignment_delivery("w", "preserved-id", &acknowledged_target),
Err(Error::TicketAssignmentConflict(_))
));
store.with_conn(|conn| {
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_delivery_requests"), requests_before);
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_delivery_acknowledgements"), acks_before);
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_deliveries"), before);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_assignment_targets WHERE delivery_id='preserved-id' AND agent_key='workspace-orchestrator'", [], |row| row.get::<_, i64>(0))?, 2);
Ok(())
}).unwrap();
}
#[tokio::test]
@@ -817,7 +827,7 @@ async fn notification_routing_missing_authorized_target_guard_refuses_startup()
"notification_assignment_targets_no_update",
"notification_assignment_targets_no_delete",
] {
let (directory, store) = tracking_store_fixture().await;
let (directory, store) = delivery_tracking_fixture().await;
store
.with_conn(|conn| {
conn.execute_batch(&format!("DROP TRIGGER {guard}"))?;
@@ -859,7 +869,7 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
Ok(snapshot(conn))
})
.unwrap();
let (fresh_directory, fresh) = tracking_store_fixture().await;
let (fresh_directory, fresh) = delivery_tracking_fixture().await;
fresh
.with_conn(|conn| {
assert_eq!(snapshot(conn), upgraded);
@@ -883,7 +893,7 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
#[tokio::test]
async fn notification_routing_content_without_recipient_records_reason_and_domain_reference() {
let (directory, store) = tracking_store_fixture().await;
let (directory, store) = delivery_tracking_fixture().await;
use ticket::TicketBackend;
let backend =
ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w")
@@ -912,7 +922,7 @@ async fn notification_routing_content_without_recipient_records_reason_and_domai
#[tokio::test]
async fn notification_routing_clear_retains_original_assignment_and_suppression_reason() {
let (_directory, store) = tracking_store_fixture().await;
let (_directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
store
@@ -939,3 +949,297 @@ async fn notification_routing_clear_retains_original_assignment_and_suppression_
assert_eq!(row,("worker-a".into(),"historical_only".into()));Ok(())
}).unwrap();
}
#[tokio::test]
async fn subject_notification_capture_uses_each_subject_current_body_and_results_never_retarget() {
let (_directory, store) = subject_notification_store().await;
subject_notification_assign(&store, "subject-ticket-a", "assigned-a", "subject-a");
subject_notification_assign(&store, "subject-ticket-b", "assigned-b", "subject-b");
let report = |operation: &str| server_api::TicketImplementationResultRequest {
operation_key: operation.into(),
kind: server_api::TicketImplementationResultKind::Result,
body: "Saved background result".into(),
merge_request: None,
};
// A Subject body's own result is not sent back to itself. The same Worker can
// report to another Subject without a global singleton stealing the recipient.
let body_a = RuntimeWorkerRef::new("r", "worker");
let own = store
.record_implementation_result(
"w",
"subject-ticket-a",
&report("own-a"),
&body_a,
"subject-binding",
)
.unwrap();
let other = store
.record_implementation_result(
"w",
"subject-ticket-b",
&report("a-to-b"),
&body_a,
"subject-binding",
)
.unwrap();
store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
store.with_conn(|conn| {
let own_reason: String = conn.query_row("SELECT d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", own.result_id)], |row| row.get(0))?;
assert_eq!(own_reason, "self_notification");
let recipient: (String, String, String, String) = conn.query_row("SELECT s.assignment_id,json_extract(s.facts_json,'$.principal_id'),json_extract(s.facts_json,'$.worker_id'),d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", other.result_id)], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?;
assert_eq!(recipient, ("assigned-b".into(), "subject-b".into(), "body-b".into(), "result".into()));
Ok(())
}).unwrap();
subject_notification_replace_body(&store, "subject-b");
let replacement = RuntimeWorkerRef::new("r", "body-replacement");
let self_after_swap = store
.record_implementation_result(
"w",
"subject-ticket-b",
&report("new-body-self"),
&replacement,
"subject-binding",
)
.unwrap();
store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
store.with_conn(|conn| {
let self_reason: String = conn.query_row("SELECT d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", self_after_swap.result_id)], |row| row.get(0))?;
assert_eq!(self_reason, "self_notification");
Ok(())
}).unwrap();
subject_notification_assign(&store, "subject-ticket-b", "new-a-assignment", "subject-a");
assert!(
!store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.iter()
.any(|input| input.input_event_id
== format!("implementation-result:{}", other.result_id))
);
// Replaying the saved report is still evidence about the original assignment.
assert_eq!(
store
.record_implementation_result(
"w",
"subject-ticket-b",
&report("a-to-b"),
&body_a,
"subject-binding"
)
.unwrap(),
other
);
store
.with_conn(|conn| {
let recipient: String = conn.query_row(
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
[format!("implementation-result:{}", other.result_id)],
|row| row.get(0),
)?;
assert_eq!(recipient, "assigned-b");
Ok(())
})
.unwrap();
}
#[test]
fn subject_notification_upgrade_preserves_legacy_sources_requests_and_receipts_and_rolls_back() {
let directory = tempfile::tempdir().unwrap();
let conn = tracking_schema91_fixture(&directory.path().join("workspace.db"));
migrate_ticket_tracking_v91_to_v92(&conn).unwrap();
migrate_assignment_delivery_v92_to_v93(&conn).unwrap();
migrate_ticket_posts_v93_to_v94(&conn).unwrap();
migrate_notification_routing_v94_to_v95(&conn).unwrap();
migrate_job_notifications_v95_to_v96(&conn).unwrap();
migrate_business_notifications_v96_to_v97(&conn).unwrap();
migrate_result_reports_v97_to_v98(&conn).unwrap();
let legacy_source = TicketAssignmentTrackingSourceContext {
actor: "legacy-delivery".into(),
runtime_id: "old-runtime".into(),
worker_id: "old-worker".into(),
subject_id: "workspace-orchestrator".into(),
authentication_binding_id: "old-binding".into(),
};
let delivery = TicketAssignmentDelivery {
ticket_id: "pending".into(),
assignment_id: "a-pending".into(),
input_event_id: "assignment-event:migration-tracking:a-pending".into(),
delivery_id: "legacy-delivery".into(),
request_id: "legacy-request".into(),
waiting_reason: None,
next_action: None,
};
conn.execute(
"INSERT INTO ticket_assignment_deliveries VALUES('w','a-pending',?1,?2,?3,?4)",
params![
delivery.input_event_id,
delivery.delivery_id,
serde_json::to_string(&delivery).unwrap(),
serde_json::to_string(&legacy_source).unwrap()
],
)
.unwrap();
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w','legacy-delivery','legacy bytes')", []).unwrap();
conn.execute(
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','legacy-delivery',?1)",
[serde_json::to_string(&legacy_source).unwrap()],
)
.unwrap();
let tables = [
"ticket_worker_assignments",
"notification_sources",
"notification_decisions",
"ticket_assignment_deliveries",
"ticket_assignment_delivery_requests",
"ticket_assignment_delivery_acknowledgements",
];
let before: Vec<_> = tables
.iter()
.map(|table| stored_fixture_rows(&conn, table))
.collect();
let triggers_before = fixture_query_rows(
&conn,
"SELECT name,sql FROM sqlite_schema WHERE type='trigger' ORDER BY name",
);
// Fail after some triggers were refreshed. The caller's transaction restores
// the entire previous capture schema, not a half-upgraded notification path.
{
let tx = conn.unchecked_transaction().unwrap();
tx.execute_batch("DROP TRIGGER notification_mr_event; CREATE TRIGGER notification_mr_event AFTER INSERT ON merge_request_thread_events BEGIN SELECT 1; END;").unwrap();
assert!(notification_routing::install_subject_recipients(&tx).is_err());
}
assert_eq!(
fixture_query_rows(
&conn,
"SELECT name,sql FROM sqlite_schema WHERE type='trigger' ORDER BY name"
),
triggers_before
);
let tx = conn.unchecked_transaction().unwrap();
notification_routing::install_subject_recipients(&tx).unwrap();
tx.commit().unwrap();
let after: Vec<_> = tables
.iter()
.map(|table| stored_fixture_rows(&conn, table))
.collect();
assert_eq!(after, before, "migration updates future capture only");
for trigger in [
"notification_ticket_event",
"notification_assignment_history",
"notification_assignment_change",
"notification_assignment_release",
"notification_content_input",
"notification_unassigned_content",
"notification_mr_event",
"notification_mr_source",
"notification_implementation_result",
] {
let sql: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name=?1",
[trigger],
|row| row.get(0),
)
.unwrap();
assert!(sql.contains("a.principal_kind='subject'"), "{trigger}");
assert!(
!sql.contains("a.principal_kind='workspace_agent'"),
"{trigger}"
);
}
assert!(
notification_routing::pending_sources(&conn, "w", "orchestrator", 100)
.unwrap()
.is_empty(),
"old principals and receipts are evidence, not new Subject requests"
);
}
#[tokio::test]
async fn notification_routing_cached_worker_attention_stops_after_ack_not_after_unknown_acceptance()
{
let (directory, store) = delivery_tracking_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
let cached = store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.remove(0);
assert!(
notification_routing::admit_worker_send(&store, "w", &cached.delivery_id, &target).is_err()
);
store
.prepare_ticket_worker_notification_content(
"w",
&cached.delivery_id,
"pinned attention",
&target,
)
.unwrap();
let mut sends = Vec::new();
let mut dispatch_cached = |store: &SqliteWorkspaceStore| {
if let Some(content) =
notification_routing::admit_worker_send(store, "w", &cached.delivery_id, &target)
.unwrap()
{
sends.push((cached.request_id.clone(), content));
}
};
dispatch_cached(&store);
store
.record_notification_delivery_failure(
"w",
&NotificationDeliveryKey::WorkerNotification {
delivery_id: cached.delivery_id.clone(),
},
NotificationFailureCode::AcceptanceUnknown,
)
.unwrap();
assert_eq!(
store
.prepare_ticket_worker_notification_content(
"w",
&cached.delivery_id,
"different rendering",
&target
)
.unwrap(),
"pinned attention"
);
dispatch_cached(&store);
let other = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
other
.ack_ticket_worker_notification("w", &cached.delivery_id, &target)
.unwrap();
dispatch_cached(&store);
assert_eq!(
sends,
vec![(cached.request_id.clone(), "pinned attention".into()); 2]
);
assert!(matches!(
store.prepare_ticket_worker_notification_content(
"w",
&cached.delivery_id,
"late render",
&target
),
Err(Error::TicketAssignmentConflict(_))
));
store
.ack_ticket_worker_notification("w", &cached.delivery_id, &target)
.unwrap();
assert!(
store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.is_empty()
);
store.with_conn(|conn| {
let content: String = conn.query_row("SELECT content FROM notification_requests WHERE workspace_id='w' AND delivery_id=?1", [&cached.delivery_id], |row| row.get(0))?;
assert_eq!(content, "pinned attention", "historical request bytes remain readable");
Ok(())
}).unwrap();
}
@@ -27,7 +27,11 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
assert_eq!(catalog_schema(c),canonical);
let after=workspace_schema_migration_history(c)?;
assert!(after.starts_with(&before));
assert_eq!(after.last().unwrap(),&(106,crate::plugin_catalog::MIGRATION_NAME.into()));
// Plugin catalog remains migration 106 even when startup applies later steps.
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
assert_eq!(after.last().unwrap(), &(107, "Subject Ticket responsibility and current-body notification routing".into()));
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
assert!(c.execute("INSERT INTO plugin_installations VALUES('absent','i','alias','p','operation',0)",[]).is_err());
@@ -57,7 +61,14 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
migrated
.with_conn(|c| {
assert_eq!(current_schema_version(c)?, 106);
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
let mut expected = before.clone();
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
expected.push((
107,
"Subject Ticket responsibility and current-body notification routing".into(),
));
assert_eq!(workspace_schema_migration_history(c)?, expected);
crate::plugin_catalog::verify(c)?;
Ok(())
})
@@ -104,6 +104,10 @@ mod schema_convergence {
version: 106,
name: crate::plugin_catalog::MIGRATION_NAME.into(),
},
WorkspaceSchemaMigrationStep {
version: 107,
name: "Subject Ticket responsibility and current-body notification routing".into(),
},
]
);
assert_eq!(
@@ -121,6 +125,7 @@ mod schema_convergence {
expected.push((104, "Converge Passkey results and Job input retention".into()));
expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into()));
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
expected.push((107, "Subject Ticket responsibility and current-body notification routing".into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected);
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ async fn attention_test_api() -> (
) {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
let (api, execution) = assignment_test_api_with_recording_backend(dir.path()).await;
attention_assign_ticket(&api, "Attention backlog");
(dir, api, execution)
}
@@ -25,18 +25,214 @@ fn attention_pending(api: &WorkspaceApi) -> Vec<crate::store::TicketAssignmentDe
.unwrap()
}
async fn start_attention_test_orchestrator(
// Operational fixtures use Subject Feature storage, never a legacy agent alias.
fn ensure_test_assignment_subject(api: &WorkspaceApi) -> String {
let store = open_subjektiv_store(api).unwrap();
if let Some(subject) = store
.list_subjects(100)
.unwrap()
.items
.into_iter()
.find(|subject| subject.role.as_str() == "assignment-fixture")
{
return subject.id;
}
store
.create_subject_with_behavior(
crate::subjektiv::SubjectRole::new("assignment-fixture").unwrap(),
"Handle only the assigned Ticket; distinguish delivery from processing.".into(),
)
.unwrap()
.id
}
fn test_assignment_principal(api: &WorkspaceApi) -> serde_json::Value {
serde_json::json!({"kind":"subject", "subject_id":ensure_test_assignment_subject(api)})
}
fn grant_test_assignment_ticket(api: &WorkspaceApi, subject_id: &str, ticket_id: &str) {
grant_test_ticket_operations(
api,
subject_id,
ticket_id,
&[
"ticket.read",
"ticket.assignment.query",
"ticket.assignment.history",
"ticket.implementation_result.write",
"ticket.assignment.assign",
"ticket.assignment.clear",
"ticket.assignment.answer",
"ticket.assignment.update",
],
);
}
fn grant_test_ticket_operations(
api: &WorkspaceApi,
) -> BrowserWorkspaceOrchestratorResponse {
scoped_start_workspace_orchestrator(
subject_id: &str,
ticket_id: &str,
operations: &[&str],
) {
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
};
let store = open_subjektiv_store(api).unwrap();
let previous = store.subject_permissions(subject_id).unwrap();
let mut execution = previous
.as_ref()
.map(|p| p.execution.clone())
.unwrap_or_default();
for operation in operations {
let grant = ScopedPermission {
operation: (*operation).into(),
resource: PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: ticket_id.into(),
},
};
if !execution.contains(&grant) {
execution.push(grant);
}
}
if previous.as_ref().is_some_and(|p| p.execution == execution) {
return;
}
store
.set_subject_permissions(
subject_id,
&SubjectPermissionsUpdate {
operation_id: Uuid::now_v7().to_string(),
expected_change_id: previous.as_ref().map(|p| p.change_id.clone()),
execution,
delegation: previous.map(|p| p.delegation).unwrap_or_default(),
},
)
.unwrap();
}
async fn create_test_assignment_body(
api: &WorkspaceApi,
runtime_id: &str,
subject_id: &str,
) -> RuntimeWorkerRef {
let Json(created) = create_workspace_worker(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
HeaderMap::new(),
Json(CreateWorkspaceWorkerRequest {
runtime_id: runtime_id.into(),
display_name: "Assignment Subject".into(),
singleton_key: None,
profile: Some("builtin:companion".into()),
ticket_assignment: None,
initial_submit: vec![],
workdir_attachments: vec![],
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
subject_id: subject_id.into(),
}),
},
control_operation_id: None,
execution_grant: None,
}),
)
.await
.unwrap()
.0
.unwrap();
RuntimeWorkerRef::new(created.runtime_id, created.worker_id)
}
fn test_assignment_body(api: &WorkspaceApi) -> Option<InternalWorkerSummary> {
let key = crate::subjektiv::subject_worker_singleton_key(&ensure_test_assignment_subject(api))
.unwrap();
let owner = api
.store
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &key)
.unwrap()?;
api.runtime.worker(&owner.worker).ok()
}
async fn start_attention_test_orchestrator(
api: &WorkspaceApi,
) -> BrowserWorkspaceOrchestratorResponse {
// Serialize fixture callers, but exercise normal Subject create/restore.
// There is no legacy launch, synthetic singleton or alternate executor.
let api = api.clone();
api.host_executors
.host()
.unwrap()
.run(move || {
let guard = api.orchestrator_spawn_lock.lock().unwrap();
seed_test_delivery_binding(&api);
let subject_id = ensure_test_assignment_subject(&api);
let (worker, disposition) = if let Some(existing) = test_assignment_body(&api) {
if workspace_orchestrator_is_online(&existing) {
(existing, "existing")
} else {
let restored =
restore_internal_observed_worker(&api, &existing, "subject-restore".into())
.unwrap();
assert_eq!(
restored.state,
server_api::WorkerRestoreState::Accepted,
"{:?}",
restored.diagnostics
);
(api.runtime.worker(&existing.worker).unwrap(), "restored")
}
} else {
let Json(created) =
futures::executor::block_on(create_workspace_worker_authenticated_on_host(
api.clone(),
HeaderMap::new(),
CreateWorkspaceWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(),
display_name: "Assignment Subject".into(),
singleton_key: None,
profile: Some("builtin:companion".into()),
ticket_assignment: None,
initial_submit: vec![],
workdir_attachments: vec![],
feature_connections:
server_api::WorkspaceWorkerFeatureConnectionsRequest {
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
subject_id,
}),
},
control_operation_id: None,
execution_grant: None,
},
None,
None,
))
.unwrap();
(
api.runtime
.worker(&RuntimeWorkerRef::new(
created.runtime_id,
created.worker_id,
))
.unwrap(),
"created",
)
};
if worker.worker.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
// Bind the canonical Session before dispatch. The deterministic
// executor uses pending metadata; scripted Controllers retain
// their already-created active Session, never a replacement ID.
bootstrap_test_assignment_session(&api, &worker.worker);
}
drop(guard);
dispatch_orchestrator_assignment_attention(&api, &worker.worker);
Ok::<_, ApiError>(BrowserWorkspaceOrchestratorResponse {
workspace_id: TEST_WORKSPACE_ID.into(),
online: workspace_orchestrator_is_online(&worker),
disposition: disposition.into(),
worker: Some(worker_launch_worker_summary(worker)),
diagnostics: vec![],
})
})
.await
.unwrap()
}
#[tokio::test]
@@ -191,6 +387,7 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
std::thread::scope(|scope| {
let api_for_caller = api.clone();
let worker_for_caller = worker.clone();
let acknowledged_for_caller = acknowledged.request_id.clone();
let caller = scope.spawn(move || {
let mut checks = 0;
with_orchestrator_attention_reservation(
@@ -215,7 +412,7 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
// but the exact same production reservation still exists.
assert_eq!(
state.lock().unwrap().accepted.as_ref().unwrap().request_id,
acknowledged.request_id
acknowledged_for_caller
);
entered_tx.send(()).unwrap();
release_rx
@@ -235,19 +432,31 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
let restored = futures::executor::block_on(start_attention_test_orchestrator(&api));
assert_eq!(restored.disposition, "restored");
let deferred = state.lock().unwrap().input_recheck;
let old_retired = state.lock().unwrap().accepted.is_none();
let acknowledged_id = state
.lock()
.unwrap()
.accepted
.as_ref()
.unwrap()
.request_id
.clone();
let inputs_before_release = execution.take_inputs();
release_tx.send(()).unwrap();
caller.join().unwrap();
assert!(deferred);
assert!(old_retired);
// Generic Subject restore preserves the local success cache; the
// durable delivery ACK, not cache retirement, prevents a resend.
assert_eq!(acknowledged_id, acknowledged.request_id);
assert!(inputs_before_release.is_empty());
});
assert!(execution.take_inputs().is_empty());
assert!(execution.take_input_request_ids().is_empty());
assert!(attention_pending(&api).is_empty());
let state = state.lock().unwrap();
assert!(state.accepted.is_none());
assert_eq!(
state.accepted.as_ref().unwrap().request_id,
acknowledged.request_id
);
assert!(!state.sending);
assert!(!state.input_recheck);
}
@@ -307,8 +516,38 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() {
let pending = state.lock().unwrap().pending.clone().unwrap();
let attempts = execution.take_inputs();
let ids = execution.take_input_request_ids();
assert_eq!(attempts.len(), 2);
assert_eq!(ids, vec![Some(pending.request_id.clone()); 2]);
// Subject creation can trigger several ordinary delivery scans. Every
// unknown-outcome attempt must preserve the exact request and payload.
assert!(!attempts.is_empty());
assert!(attempts.iter().all(|attempt| attempt.1 == pending.content));
assert_eq!(ids, vec![Some(pending.request_id.clone()); attempts.len()]);
dispatch_orchestrator_assignment_attention(&api, &worker);
let explicit_retry = execution.take_inputs();
assert!(!explicit_retry.is_empty());
assert!(
explicit_retry
.iter()
.all(|attempt| attempt.1 == pending.content)
);
assert_eq!(
execution.take_input_request_ids(),
vec![Some(pending.request_id.clone()); explicit_retry.len()]
);
// Complement the known-ACK cached resend regression below. An uncertain
// transport outcome has NO durable acceptance, so the existing request must
// remain retryable. Persistent Runtime receipt recovery is exercised by the
// notification delivery fixture rather than reimplemented by this adapter.
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let pending_acks = || {
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
rusqlite::params![TEST_WORKSPACE_ID, pending.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
};
let pinned_bytes = || {
conn.query_row("SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",
rusqlite::params![TEST_WORKSPACE_ID, pending.input.delivery_id], |row| row.get::<_, String>(0)).unwrap()
};
assert_eq!(pending_acks(), 0);
assert_eq!(pinned_bytes(), pending.content);
// Change the backlog before retry: uncertain acceptance must never reuse
// the ID for a newly rendered payload.
attention_assign_ticket(&api, "Changed backlog");
@@ -327,7 +566,9 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() {
assert!(retry[1].1.contains("Changed backlog"));
let ids = execution.take_input_request_ids();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], Some(pending.request_id));
assert_eq!(ids[0], Some(pending.request_id.clone()));
assert_eq!(pending_acks(), 1);
assert_eq!(pinned_bytes(), pending.content);
assert_ne!(ids[1], ids[0]);
assert!(state.lock().unwrap().pending.is_none());
assert!(state.lock().unwrap().accepted.is_some());
@@ -414,7 +655,7 @@ impl AttentionWorkerFactory {
worker_id: String,
restore: bool,
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
let name = format!("attention-{worker_id}");
let name = format!("worker-runtime-{worker_id}");
let root = self.root.join("workers").join(&worker_id);
let store = session_store::CombinedStore::new(
session_store::WorkerSessionStore::new(root.join("session"))
@@ -789,12 +1030,16 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
.unwrap();
let restored = start_attention_test_orchestrator(&api).await;
assert_eq!(restored.disposition, "restored");
assert!(
assert_eq!(
orchestrator_attention_state(&api, &worker)
.lock()
.unwrap()
.accepted
.is_none()
.as_ref()
.unwrap()
.request_id,
first_id,
"generic Subject restore preserves the acknowledged delivery cache"
);
assert!(attention_pending(&api).is_empty());
assert_eq!(
@@ -803,8 +1048,13 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
);
assert_eq!(receipts().len(), 1);
assert_eq!(commits(), 1);
// Retry directly at the real Controller boundary: the same Worker's
// persisted receipt survives restore even after server memory is retired.
// Explicitly simulate loss of the server's optional success cache. Retry
// directly at the real Controller boundary: its persisted receipt survives
// restore independently of server memory.
orchestrator_attention_state(&api, &worker)
.lock()
.unwrap()
.accepted = None;
let retry = api
.runtime
.send_input(
@@ -918,15 +1168,9 @@ async fn orchestrator_attention_restore_and_replacement_do_not_resend_acknowledg
"existing"
);
assert!(execution.take_inputs().is_empty());
// This is a fixture Worker, not the running dogfooding Orchestrator.
api.runtime.delete_worker(&worker).unwrap();
api.worker_projection
.publish_ordered(|| {
api.store
.delete_worker_registry(TEST_WORKSPACE_ID, &worker)
.map(|commit| ((), commit))
})
.unwrap();
// Connected Subject occupancy is released only by the normal Host protocol,
// even though this fixture substitutes the deterministic Controller.
remove_test_assignment_body(&api, &worker).await;
let replacement = start_attention_test_orchestrator(&api).await;
let replacement_id = replacement.worker.unwrap().worker_id;
assert_ne!(replacement_id, worker.worker_id);
@@ -1020,8 +1264,8 @@ async fn orchestrator_attention_close_reopen_does_not_revive_old_input_but_reass
ticket_id: original.input.ticket_id.clone(),
assignment_id: "attention-reassigned".into(),
role: TicketAssignmentRole::Orchestrator,
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
principal: TicketAssignmentPrincipal::Subject {
subject_id: ensure_test_assignment_subject(&api),
},
assigned_by: "test-user".into(),
assigned_at: "2026-09-02T00:00:00Z".into(),
@@ -1149,3 +1393,219 @@ async fn orchestrator_running_to_idle_recovers_unreceived_input_without_notifica
"foreign_workspace_ticket_projection"
);
}
// Lookup-only fault adapter around the existing fixture Runtime. It is installed
// after normal Subject body creation and owns no launch/delegation implementation.
// All observations and sends retain the existing adapter's behavior; only one
// selected Runtime.worker() return runs the deterministic policy-change callback.
struct AssignmentLookupHookRuntime {
inner: WorkdirlessFixtureRuntime,
hook: Mutex<Option<Box<dyn FnOnce() + Send>>>,
}
impl crate::hosts::WorkspaceWorkerRuntime for AssignmentLookupHookRuntime {
fn runtime_id(&self) -> &str {
crate::hosts::WorkspaceWorkerRuntime::runtime_id(&self.inner)
}
fn runtime_summary(&self, limit: usize) -> crate::hosts::InternalRuntimeSummary {
crate::hosts::WorkspaceWorkerRuntime::runtime_summary(&self.inner, limit)
}
fn list_hosts(
&self,
limit: usize,
) -> crate::hosts::RuntimeList<crate::hosts::InternalHostSummary> {
crate::hosts::WorkspaceWorkerRuntime::list_hosts(&self.inner, limit)
}
fn list_workers(&self, limit: usize) -> crate::hosts::RuntimeList<InternalWorkerSummary> {
crate::hosts::WorkspaceWorkerRuntime::list_workers(&self.inner, limit)
}
fn worker(&self, worker_id: &str) -> crate::hosts::WorkerLookupResult {
let observed = crate::hosts::WorkspaceWorkerRuntime::worker(&self.inner, worker_id);
let hook = self.hook.lock().unwrap().take();
if let Some(hook) = hook {
hook();
}
observed
}
fn send_input(
&self,
worker_id: &str,
request: WorkerInputRequest,
) -> crate::hosts::WorkerInputResult {
crate::hosts::WorkspaceWorkerRuntime::send_input(&self.inner, worker_id, request)
}
}
#[tokio::test]
async fn subject_assignment_cached_send_rechecks_policy_after_runtime_target_lookup() {
use server_api::authorization::SubjectPermissionsUpdate;
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
let runtime = WorkdirlessFixtureRuntime::default();
api.runtime.register_or_replace(runtime.clone());
let subject_id = ensure_test_assignment_subject(&api);
let body =
create_test_assignment_body(&api, WorkdirlessFixtureRuntime::RUNTIME_ID, &subject_id).await;
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new("Private assignment input"))
.unwrap();
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
api.store
.set_current_ticket_role_assignment(
&TicketRoleAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: ticket.id.clone(),
assignment_id: "policy-lookup-assignment".into(),
role: TicketAssignmentRole::Orchestrator,
principal: TicketAssignmentPrincipal::Subject {
subject_id: subject_id.clone(),
},
assigned_by: "fixture-owner".into(),
assigned_at: TEST_CREATED_AT.into(),
},
None,
"policy-lookup-event",
"policy-lookup-assign",
false,
)
.unwrap();
let input = api
.store
.list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 20)
.unwrap()
.remove(0);
let target = orchestrator_delivery_target(&api, &body).unwrap();
let input = api
.store
.reserve_ticket_assignment_delivery(
TEST_WORKSPACE_ID,
&input.assignment_id,
&input.input_event_id,
&target,
)
.unwrap();
let content = api
.store
.prepare_ticket_assignment_delivery_content(
TEST_WORKSPACE_ID,
&input.delivery_id,
"Pinned private Ticket context",
&target,
)
.unwrap();
let delivery = OrchestratorAttentionDelivery {
fingerprint: input.delivery_id.clone(),
request_id: input.request_id.clone(),
content,
input,
};
let state = orchestrator_attention_state(&api, &body);
state.lock().unwrap().pending = Some(delivery.clone());
let callback_ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
let called = callback_ran.clone();
let revoking_api = api.clone();
let revoking_body = body.clone();
let revoking_ticket = ticket.id.clone();
let revoking_subject = subject_id.clone();
api.runtime
.register_or_replace(AssignmentLookupHookRuntime {
inner: runtime.clone(),
hook: Mutex::new(Some(Box::new(move || {
// The first authorization already passed. Policy is still live at
// this exact target lookup, then changes before the final send.
authorize_assignment_delivery(&revoking_api, &revoking_body, &revoking_ticket)
.unwrap();
let store = open_subjektiv_store(&revoking_api).unwrap();
let previous = store
.subject_permissions(&revoking_subject)
.unwrap()
.unwrap();
store
.set_subject_permissions(
&revoking_subject,
&SubjectPermissionsUpdate {
operation_id: "revoke-at-target-lookup".into(),
expected_change_id: Some(previous.change_id),
execution: vec![],
delegation: vec![],
},
)
.unwrap();
called.store(true, std::sync::atomic::Ordering::SeqCst);
}))),
});
assert!(runtime.input_requests.lock().unwrap().is_empty());
// Invoke the real cached-send path. Its next Runtime.worker() call is the
// target observation between initial authorization and DB/send admission.
send_orchestrator_attention(&api, &body, &state, delivery.clone());
assert!(callback_ran.load(std::sync::atomic::Ordering::SeqCst));
assert!(authorize_assignment_delivery(&api, &body, &ticket.id).is_err());
assert!(
runtime.input_requests.lock().unwrap().is_empty(),
"revoked context reached Runtime send_input"
);
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let acknowledgements = || {
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
rusqlite::params![TEST_WORKSPACE_ID, delivery.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
};
assert_eq!(acknowledgements(), 0);
assert!(
state.lock().unwrap().pending.is_some(),
"denied send must not manufacture acceptance"
);
assert!(state.lock().unwrap().accepted.is_none());
// Positive control: explicit policy restoration can admit the SAME pinned
// request. The target/reservation was valid; no new delegation was needed.
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
send_orchestrator_attention(&api, &body, &state, delivery.clone());
let inputs = runtime.input_requests.lock().unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(
inputs[0].submission_request_id.as_deref(),
Some(delivery.request_id.as_str())
);
assert_eq!(inputs[0].content, delivery.content);
assert_eq!(acknowledgements(), 1);
assert!(state.lock().unwrap().pending.is_none());
}
#[tokio::test]
async fn subject_assignment_known_ack_closes_cached_pending_resend_admission() {
let (_dir, api, execution) = attention_test_api().await;
let started = start_attention_test_orchestrator(&api).await;
let body = RuntimeWorkerRef::new(
EMBEDDED_WORKER_RUNTIME_ID,
started.worker.unwrap().worker_id,
);
let state = orchestrator_attention_state(&api, &body);
let acknowledged = state.lock().unwrap().accepted.clone().unwrap();
assert!(attention_pending(&api).is_empty());
execution.take_inputs();
execution.take_input_request_ids();
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let acks = || {
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
rusqlite::params![TEST_WORKSPACE_ID, acknowledged.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
};
assert_eq!(
acks(),
1,
"positive Runtime acceptance must already be durable"
);
// Model a stale local pending slot after another sender committed its ACK.
// Memory does not regain send authority merely by holding the original bytes.
state.lock().unwrap().pending = Some(acknowledged.clone());
send_orchestrator_attention(&api, &body, &state, acknowledged.clone());
assert!(execution.take_inputs().is_empty());
assert!(execution.take_input_request_ids().is_empty());
assert!(state.lock().unwrap().pending.is_none());
assert_eq!(acks(), 1);
assert!(attention_pending(&api).is_empty());
assert_eq!(
state.lock().unwrap().accepted.as_ref().unwrap().request_id,
acknowledged.request_id
);
}
@@ -87,13 +87,14 @@ async fn delivery_http(
}
async fn delivery_assign(api: &WorkspaceApi, ticket: &str, key: &str) -> Value {
grant_test_assignment_ticket(api, &ensure_test_assignment_subject(api), ticket);
delivery_http(
api,
"PUT",
&delivery_assignment_path(ticket),
Some(json!({
"operation_id": key,
"principal": {"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}
"principal": test_assignment_principal(&api)
})),
StatusCode::OK,
)
@@ -152,7 +153,7 @@ async fn delivery_recording_fixture(
RuntimeWorkerRef,
RuntimeIdentityMaterial,
) {
let (mut api, execution) = test_api_with_recording_backend(root).await;
let (mut api, execution) = assignment_test_api_with_recording_backend(root).await;
seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let worker = delivery_worker(start_attention_test_orchestrator(&api).await);
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
@@ -617,7 +618,7 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop
"clear/reissue must not rewrite the old status to done"
);
assert_eq!(
find_workspace_orchestrator(&api).unwrap().worker,
test_assignment_body(&api).unwrap().worker,
worker,
"clear/reassign are not Worker stop or replacement"
);
@@ -782,34 +783,46 @@ struct DeliveryWorkerFactory {
}
impl DeliveryWorkerFactory {
fn manifest(
worker_id: &str,
profile: &worker_runtime::catalog::ProfileSelector,
bundle: Option<&worker_runtime::config_bundle::ConfigBundle>,
) -> std::result::Result<manifest::WorkerManifest, String> {
let selector = match profile {
worker_runtime::catalog::ProfileSelector::Builtin(value)
| worker_runtime::catalog::ProfileSelector::Named(value) => value,
};
let mut manifest = crate::profile_settings::resolve_profile_manifest_from_config_bundle(
bundle.ok_or("delivery fixture requires the normal resolved Config bundle")?,
selector,
)
.map_err(|e| e.to_string())?;
manifest.worker.name = format!("worker-runtime-{worker_id}");
// These notification fixtures attach no Workdirs. Apply that zero-file
// launch ceiling rather than resolving Profile scope against the test cwd.
manifest.scope.allow.clear();
// Retain the resolved Profile; bootstrap saves the actual execution policy,
// including that ceiling, through its normal metadata writer.
assert!(manifest.requires_persisted_execution_snapshot());
Ok(manifest)
}
async fn controller(
&self,
worker_id: String,
restore: bool,
manifest: manifest::WorkerManifest,
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
let name = format!("delivery-{worker_id}");
let name = format!("worker-runtime-{worker_id}");
let root = self.root.join("workers").join(&worker_id);
let store = session_store::CombinedStore::new(
WorkerSessionStore::new(root.join("session")).map_err(|e| e.to_string())?,
session_store::WorkerAggregateStore::new(&root, &name).map_err(|e| e.to_string())?,
);
let manifest = manifest::WorkerManifest::from_toml(&format!(
r#"
[worker]
name = "{name}"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "scripted-delivery"
auth = {{ kind = "none" }}
[engine]
max_tokens = 64
[scope]
allow = []
"#
))
.map_err(|e| e.to_string())?;
let context = worker::WorkerWorkspaceContext::local_filesystem(None);
let context = worker::WorkerWorkspaceContext::local_filesystem(Some(
worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap(),
));
let workspace_client = context.client_handle();
let bootstrap = worker::WorkerBootstrap::new(
manifest,
@@ -827,7 +840,7 @@ impl DeliveryWorkerFactory {
} else {
bootstrap.prepare().await
}
.map_err(|e| e.to_string())?;
.map_err(|e| format!("{e:?}"))?;
let started = prepared.start().await.map_err(|e| e.to_string())?;
Ok(worker_runtime::worker_backend::RuntimeWorkerController {
handle: started.handle,
@@ -845,8 +858,13 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
&self,
request: delivery_execution::WorkerExecutionSpawnRequest,
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
self.controller(request.worker_ref.worker_id.to_string(), false)
.await
let worker_id = request.worker_ref.worker_id.to_string();
let manifest = Self::manifest(
&worker_id,
&request.request.profile,
request.config_bundle.as_ref(),
)?;
self.controller(worker_id, false, manifest).await
}
async fn prepare_restore(
&self,
@@ -855,13 +873,27 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
Box<dyn worker_runtime::worker_backend::RuntimeRestoreCandidate>,
worker_runtime::worker_backend::RuntimeRestoreFailure,
> {
let worker_id = request.worker_ref.worker_id.to_string();
// Restore must read the saved execution snapshot, not reselect today's
// Config/Profile (a reopened Runtime need not have a cached bundle).
// Workspace-bound prepare_restored rejects a missing saved snapshot.
let mut fallback = manifest::WorkerManifestConfig::resolution_defaults();
fallback.worker.name = Some(format!("worker-runtime-{worker_id}"));
let manifest = manifest::WorkerManifest::try_from(fallback).map_err(|error| {
eprintln!("delivery fixture restore defaults failed: {error}");
worker_runtime::worker_backend::RuntimeRestoreFailure::refused(
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
"delivery fixture restore defaults failed",
)
})?;
Ok(Box::new(DeliveryRestoreCandidate {
inputs: Some((
DeliveryWorkerFactory {
root: self.root.clone(),
client: self.client.clone(),
},
request.worker_ref.worker_id.to_string(),
worker_id,
manifest,
)),
}))
}
@@ -870,7 +902,7 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
// Retain the same scripted client/receipt store without opening either store or
// starting a Controller until activation. Discard drops only these owned inputs.
struct DeliveryRestoreCandidate {
inputs: Option<(DeliveryWorkerFactory, String)>,
inputs: Option<(DeliveryWorkerFactory, String, manifest::WorkerManifest)>,
}
#[async_trait::async_trait]
@@ -885,18 +917,22 @@ impl worker_runtime::worker_backend::RuntimeRestoreCandidate for DeliveryRestore
worker_runtime::worker_backend::RuntimeWorkerController,
worker_runtime::worker_backend::RuntimeRestoreFailure,
> {
let (factory, worker_id) = self.inputs.take().ok_or_else(|| {
let (factory, worker_id, manifest) = self.inputs.take().ok_or_else(|| {
worker_runtime::worker_backend::RuntimeRestoreFailure::refused(
worker_runtime::worker_backend::RuntimeRestoreStage::Controller,
"delivery fixture candidate already consumed",
)
})?;
factory.controller(worker_id, true).await.map_err(|_| {
worker_runtime::worker_backend::RuntimeRestoreFailure::unproven(
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
"delivery fixture reconstruction failed",
)
})
factory
.controller(worker_id, true, manifest)
.await
.map_err(|error| {
eprintln!("delivery fixture reconstruction failed: {error}");
worker_runtime::worker_backend::RuntimeRestoreFailure::unproven(
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
"delivery fixture reconstruction failed",
)
})
}
async fn discard(
@@ -919,6 +955,12 @@ impl delivery_execution::WorkerExecutionBackend for DeliveryResponseLossBackend
fn backend_id(&self) -> &str {
self.inner.backend_id()
}
fn subject_release(
&self,
request: &runtime_api::SubjectReleaseRequest,
) -> std::result::Result<runtime_api::SubjectReleaseResponse, String> {
self.inner.subject_release(request)
}
fn spawn_worker(
&self,
r: delivery_execution::WorkerExecutionSpawnRequest,
@@ -1364,17 +1406,24 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
let initial = delivery_tracking(&api, &handled.id, assignment);
let old_worker = delivery_worker(start_attention_test_orchestrator(&api).await);
delivery_wait_idle(&api, &old_worker, 1).await;
delivery_stop(&api, &old_worker);
// Delete only this isolated fixture's stopped identity. A new live Worker
// has a new session; it is not a restore and cannot reuse old session receipts.
api.runtime.delete_worker(&old_worker).unwrap();
api.worker_projection
.publish_ordered(|| {
api.store
.delete_worker_registry(TEST_WORKSPACE_ID, &old_worker)
.map(|commit| ((), commit))
})
.unwrap();
assert_eq!(execution.attempts.lock().unwrap().len(), 1);
// The factory has no Journal tool wiring. Drive real Host admission and the
// dispatched Journal Job's sequential services over the actual saved source,
// rather than bypassing the normal release coverage invariant.
reflect_test_assignment_session(
&api,
&old_worker,
"Read the delegation; returning without starting a Coder.",
)
.await;
let reflected_attempts = execution.attempts.lock().unwrap().len();
assert_eq!(
reflected_attempts, 2,
"one delivery plus one actual Journal Job"
);
// Release while the actual Controller is Idle, before stopping/deleting its
// retained identity. Deletion itself cannot release connected Subject occupancy.
remove_test_assignment_body(&api, &old_worker).await;
let pending = delivery_ticket(&api, "Pending input for replacement");
let new_assign = delivery_assign(&api, &pending.id, "assign-pending-replacement").await;
let pending_assignment = new_assign["assignment"]["assignment_id"].as_str().unwrap();
@@ -1391,7 +1440,10 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
!commits[0].contains(handled.resource_key.as_deref().unwrap()),
"replacement must not execute acknowledged history"
);
assert_eq!(execution.attempts.lock().unwrap().len(), 2);
assert_eq!(
execution.attempts.lock().unwrap().len(),
reflected_attempts + 1
);
assert_eq!(
delivery_tracking(&api, &handled.id, assignment),
initial,
@@ -1402,7 +1454,10 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
pending_initial
);
delivery_scan(&api, &replacement);
assert_eq!(execution.attempts.lock().unwrap().len(), 2);
assert_eq!(
execution.attempts.lock().unwrap().len(),
reflected_attempts + 1
);
delivery_stop(&api, &replacement);
}
@@ -7,64 +7,56 @@ use super::*;
struct StartSource {
identity: RuntimeIdentityMaterial,
worker: RuntimeWorkerRef,
delegation: Vec<server_api::authorization::ScopedPermission>,
}
async fn start_source(api: &mut WorkspaceApi, runtime: &WorkdirlessFixtureRuntime) -> StartSource {
let runtime_id = WorkdirlessFixtureRuntime::RUNTIME_ID;
async fn start_source(api: &mut WorkspaceApi, _runtime: &WorkdirlessFixtureRuntime) -> StartSource {
start_body_source(api, WorkdirlessFixtureRuntime::RUNTIME_ID).await
}
async fn start_body_source(api: &mut WorkspaceApi, runtime_id: &str) -> StartSource {
let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap();
configure_runtime_request_auth(api, &identity, runtime_id);
let memory = api
.config_store
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
.unwrap();
let reservation = api
.config_store
.reserve_worker_create(
TEST_WORKSPACE_ID,
runtime_id,
"start-orchestrator",
"sha256:start-orchestrator",
Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY),
&memory,
let subject_id = ensure_test_assignment_subject(api);
let worker = create_test_assignment_body(api, runtime_id, &subject_id).await;
for ticket in api.authority.list_tickets(100).unwrap().items {
grant_test_assignment_ticket(api, &subject_id, &ticket.id);
}
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
};
let delegation: Vec<_> = api
.store
.list_workdir_registry(TEST_WORKSPACE_ID, 100)
.unwrap()
.into_iter()
.map(|workdir| ScopedPermission {
operation: "filesystem.read".into(),
resource: PermissionResource::Workdir {
working_directory_id: workdir.workdir_id,
path: ".".into(),
recursive: true,
},
})
.collect();
let store = open_subjektiv_store(api).unwrap();
let previous = store.subject_permissions(&subject_id).unwrap();
store
.set_subject_permissions(
&subject_id,
&SubjectPermissionsUpdate {
operation_id: "start-source-delegation".into(),
expected_change_id: previous.as_ref().map(|policy| policy.change_id.clone()),
execution: previous.map(|policy| policy.execution).unwrap_or_default(),
delegation: delegation.clone(),
},
)
.unwrap();
let mut summary = WorkdirlessFixtureRuntime::worker_summary(
WorkerCreateBinding {
worker_id: reservation.worker_id,
create_fingerprint: reservation.create_fingerprint,
},
&WorkerSpawnRequest {
requested_worker_name: Some("Start Orchestrator".into()),
singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()),
intent: WorkerSpawnIntent::WorkspaceCompanion,
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: ProfileSelector::Builtin("builtin:orchestrator".into()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
);
summary.tags = vec!["orchestrator".into(), "singleton".into()];
let worker = summary.worker.clone();
runtime.workers.lock().unwrap().push(summary);
seed_worker_source_member(api, runtime_id, &worker.worker_id);
api.config_store
.complete_worker_create_reservation(TEST_WORKSPACE_ID, worker.worker_id.parse().unwrap())
.unwrap();
assert_eq!(find_workspace_orchestrator(api).unwrap().worker, worker);
StartSource { identity, worker }
StartSource {
identity,
worker,
delegation,
}
}
fn add_unfinished_start_dependency(api: &WorkspaceApi, ticket_id: &str) -> String {
@@ -91,8 +83,37 @@ fn start_http_request(
token: &str,
method: &str,
path: &str,
body: Value,
mut body: Value,
) -> Request<Body> {
if let Some(source) =
source.filter(|_| method == "POST" && path.ends_with("/worker-control/workers"))
{
// Supply the normal explicit least-authority child request. Only the
// selected reference Workdirs receive read; no write/command is issued.
let attachments = body["workdir_attachments"]
.as_array()
.cloned()
.unwrap_or_default();
let permissions: Vec<_> = source
.delegation
.iter()
.filter(|permission| {
let server_api::authorization::PermissionResource::Workdir {
working_directory_id,
..
} = &permission.resource
else {
return false;
};
attachments.iter().any(|attachment| {
attachment["working_directory_id"].as_str()
== Some(working_directory_id.as_str())
})
})
.cloned()
.collect();
body["execution_grant"] = serde_json::json!({"permissions":permissions, "redelegation":[]});
}
let body = serde_json::to_vec(&body).unwrap();
match source {
Some(source) => runtime_source_request(
@@ -182,6 +203,54 @@ fn assert_start_dependency_diagnostic_only(api: &WorkspaceApi, ticket: &str, dep
assert!(prerequisite.assignment_tracking.is_empty());
}
#[tokio::test]
async fn ticket_start_rechecks_changed_target_after_allocation_before_runtime_dispatch() {
for change in ["access='read_only'", "ref_selector='different-branch'"] {
let fixture = guarded_spawn_fixture().await;
let conn = rusqlite::Connection::open(&fixture.api.config.database_path).unwrap();
// Deterministic interleaving at a real persistence boundary: the initial
// target check has finished, but no Runtime spawn has been dispatched.
conn.execute_batch(&format!(
"CREATE TRIGGER narrow_target_after_allocation AFTER INSERT ON worker_create_reservations
WHEN NEW.runtime_id='workdirless-runtime'
BEGIN UPDATE typed_ticket_targets SET {change}
WHERE ticket_id='{}' AND repository_key='test-repository'; END;",
fixture.ticket_id,
)).unwrap();
let body = guarded_spawn_payload(&fixture, "target-changed-before-dispatch");
let response = post_guarded_spawn(&fixture, body).await;
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert!(
matches!(status, StatusCode::FORBIDDEN | StatusCode::BAD_REQUEST),
"{change}: {status}: {}",
String::from_utf8_lossy(&bytes)
);
assert!(
fixture.runtime.spawn_requests().is_empty(),
"{change}: no Runtime effect"
);
assert!(
fixture
.api
.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id,)
.unwrap()
.is_none()
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM worker_workdir_attachment_reservations",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0,
"failed admission must release newly reserved attachments"
);
}
}
#[tokio::test]
async fn unfinished_dependencies_allow_manual_and_orchestrator_resource_free_start_and_assign() {
for orchestrator in [false, true] {
@@ -590,7 +659,7 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
ticket.id
);
start_http_response(&api, start_http_request(None, &token, "PUT", &path,
serde_json::json!({"operation_id":"reissue-observed", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})), StatusCode::OK).await;
serde_json::json!({"operation_id":"reissue-observed", "principal":test_assignment_principal(&api)})), StatusCode::OK).await;
assert_ne!(
observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(),
observed
@@ -664,11 +733,12 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_delegation() {
for existing in [false, true] {
let directory = tempfile::tempdir().unwrap();
let mut api = test_api(directory.path()).await;
let (mut api, _) = assignment_test_api_with_recording_backend(directory.path()).await;
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
let runtime = WorkdirlessFixtureRuntime::default();
api.runtime.register_or_replace(runtime.clone());
let source = start_source(&mut api, &runtime).await;
let source = start_body_source(&mut api, EMBEDDED_WORKER_RUNTIME_ID).await;
bootstrap_test_assignment_session(&api, &source.worker);
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "obsolete-worker-owner").await;
let ticket = browser_ticket_backend(&api)
@@ -697,21 +767,16 @@ async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_del
} else {
None
};
// Backend ownership changes independently of the retained assignment row.
let replacement =
RuntimeWorkerRef::new(&source.worker.runtime_id, WorkerId::now_v7().to_string());
seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id);
let mut summary = runtime.workers.lock().unwrap()[0].clone();
summary.worker = replacement.clone();
runtime.workers.lock().unwrap().push(summary);
rusqlite::Connection::open(&api.config.database_path).unwrap().execute(
"UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3",
rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY],
).unwrap();
assert_eq!(
find_workspace_orchestrator(&api).unwrap().worker,
replacement
);
// Body replacement preserves Subject assignment, but rotates the real
// create reservation/singleton generation through normal Subject launch.
remove_test_assignment_body(&api, &source.worker).await;
let replacement = create_test_assignment_body(
&api,
&source.worker.runtime_id,
&ensure_test_assignment_subject(&api),
)
.await;
assert_eq!(test_assignment_body(&api).unwrap().worker, replacement);
assert_eq!(
observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(),
delegation
@@ -0,0 +1,406 @@
// Test-only Subject lifetime support. Session metadata and Host writer binding
// are real; the deterministic executor substitutes only the Controller protocol.
// Tests requiring real Controller release use the connected Runtime suites.
async fn assignment_test_api_with_recording_backend(
root: impl Into<PathBuf>,
) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) {
let (api, execution) = test_api_with_recording_backend(root).await;
execution
.assignment_release_enabled
.store(true, std::sync::atomic::Ordering::SeqCst);
(api, execution)
}
fn bootstrap_test_assignment_session(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
};
use session_store::WorkerMetadataStore;
assert_eq!(worker.runtime_id, EMBEDDED_WORKER_RUNTIME_ID);
let lease = api
.store
.require_current_worker_singleton_owner(api.workspace_id(), worker)
.unwrap()
.unwrap();
let subject = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX).unwrap();
let store = open_subjektiv_store(api).unwrap();
let previous = store.subject_permissions(subject).unwrap();
let mut execution = previous
.as_ref()
.map(|p| p.execution.clone())
.unwrap_or_default();
let before = execution.clone();
for operation in ["session.record", "journal.recording.request"] {
let permission = ScopedPermission {
operation: operation.into(),
resource: PermissionResource::Subject {
workspace_id: api.workspace_id().into(),
subject_id: subject.into(),
},
};
if !execution.contains(&permission) {
execution.push(permission);
}
}
if before != execution {
store
.set_subject_permissions(
subject,
&SubjectPermissionsUpdate {
operation_id: format!("assignment-session-{}", worker.worker_id),
expected_change_id: previous.as_ref().map(|p| p.change_id.clone()),
execution,
delegation: previous.map(|p| p.delegation).unwrap_or_default(),
},
)
.unwrap();
}
let name = format!("worker-runtime-{}", worker.worker_id);
let aggregate = session_store::worker_metadata::WorkerAggregateStore::new(
api.config
.embedded_runtime_store_root
.join("workers")
.join(&worker.worker_id),
&name,
)
.unwrap();
let metadata = aggregate.read_read_only().unwrap();
let session = if let Some(active) = metadata.as_ref().and_then(|m| m.active.as_ref()) {
active.session_id
} else {
let session = session_store::new_session_id();
let mut metadata =
metadata.unwrap_or_else(|| session_store::WorkerMetadata::new(&name, None));
metadata.active = Some(session_store::WorkerActiveSegmentRef::pending_segment(
session,
));
aggregate
.write(&metadata.with_workspace_id(api.workspace_id()))
.unwrap();
session
};
subject_authorization::authorize(
api,
worker,
&server_api::authorization::OperationAuthorizationRequest {
operation: "session.record".into(),
resource: PermissionResource::Subject {
workspace_id: api.workspace_id().into(),
subject_id: subject.into(),
},
},
)
.unwrap();
subjektiv_journal::bind_host_session(
api,
crate::subjektiv::SubjectSessionAttribution::new(
subject,
&worker.runtime_id,
&worker.worker_id,
session.to_string(),
)
.unwrap(),
)
.unwrap();
}
// The delivery factory persists real canonical Session bytes, but its scripted model
// has no Journal tools. Substitute only the sequential model/tool calls here: normal
// Host admission selects the saved source, dispatches the immutable BackendJob input
// and attests actual Runtime acceptance. No fixture source or delivery proof is seeded.
async fn reflect_test_assignment_session(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
expected_public_output: &str,
) {
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
};
use subjektiv::journal::transport::{JournalJobRequest, JournalJobResponse};
use subjektiv::journal::{JournalAction, JournalEntryState, JournalOperation};
let lease = api
.store
.require_current_worker_singleton_owner(api.workspace_id(), worker)
.unwrap()
.unwrap();
let subject = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX).unwrap();
let store = open_subjektiv_store(api).unwrap();
let previous = store.subject_permissions(subject).unwrap().unwrap();
let mut execution = previous.execution.clone();
for operation in [
"journal.job.input.read",
"journal.job.input.supply",
"journal.state.read",
"journal.entry.create",
"journal.position.update",
"journal.checkpoint",
"journal.finish",
] {
let permission = ScopedPermission {
operation: operation.into(),
resource: PermissionResource::Subject {
workspace_id: api.workspace_id().into(),
subject_id: subject.into(),
},
};
if !execution.contains(&permission) {
execution.push(permission);
}
}
store
.set_subject_permissions(
subject,
&SubjectPermissionsUpdate {
operation_id: format!("assignment-journal-{}", worker.worker_id),
expected_change_id: Some(previous.change_id.clone()),
execution,
delegation: previous.delegation.clone(),
},
)
.unwrap();
let saved_permissions = store.subject_permissions(subject).unwrap().unwrap();
assert!(
previous
.execution
.iter()
.all(|permission| saved_permissions.execution.contains(permission))
);
assert_eq!(saved_permissions.delegation, previous.delegation);
let attrs = store
.subject_session_attributions(subject)
.unwrap()
.into_iter()
.filter(|a| a.runtime_id == worker.runtime_id && a.worker_id == worker.worker_id)
.collect::<Vec<_>>();
assert_eq!(attrs.len(), 1, "fixture expects one actual saved Session");
let attr = &attrs[0];
subjektiv_journal::reconcile_created_worker(api, worker).unwrap();
let (job, attempt) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
let jobs = api
.store
.list_backend_journal_jobs(api.workspace_id(), &attr.session_id, None, 100)
.unwrap();
assert!(
jobs.len() <= 1,
"unexpected duplicate Journal intent: {jobs:?}"
);
if let Some(job) = jobs.into_iter().next() {
let attempt = api
.store
.get_backend_job_attempt(
api.workspace_id(),
&job.request.job_id,
&crate::backend_job::attempt_id(&job.request.job_id, job.current_attempt),
)
.unwrap()
.unwrap();
assert!(
!attempt.state.terminal(),
"Journal dispatch failed before reflection: {attempt:?}"
);
if attempt.state == BackendJobAttemptState::Dispatched {
break (job, attempt);
}
}
tokio::task::yield_now().await;
}
})
.await
.expect("actual Journal BackendJob was not dispatched");
let journal_worker = attempt.worker.as_ref().unwrap();
assert_ne!(journal_worker, worker);
let input = subjektiv::journal::recording::JournalInput::decode(&job.request.input).unwrap();
let canonical = subjektiv_journal::capture_input(api, attr, 0, 0, 48 * 1024).unwrap();
assert_eq!(input.capture.entries, canonical.entries);
assert!(!input.selected_ranges.is_empty());
let captured_text = input
.capture
.entries
.iter()
.map(|entry| match &entry.body {
session_store::capture::CaptureBody::CanonicalPublic { text, .. } => text.as_str(),
_ => panic!("only actual canonical public material is eligible"),
})
.collect::<Vec<_>>()
.join("\n");
// System notifications are not canonical public recording material. Reflect
// the actual assistant output, not a reconstructed copy of its notification.
assert!(captured_text.contains(expected_public_output));
let JournalJobResponse::State(state) =
subjektiv_journal::execute(api, journal_worker, JournalJobRequest::ReadState).unwrap()
else {
panic!("bound Journal state required")
};
assert_eq!(state.selected_ranges, input.selected_ranges);
assert_eq!(state.initial_journal.state_token, input.initial_state_token);
assert!(state.current_open.is_none());
let record_id = format!("record:{}", state.generation_id);
let mut token = state.current_journal.state_token;
for (request_id, action, covered_ranges) in [
(
record_id.clone(),
JournalAction::Create {
title: "Assignment delivery before body replacement".into(),
body: "Received the Ticket delegation. The scripted body returned without starting a Coder; no implementation result is asserted.".into(),
ongoing: "Keep the Subject assignment; handle future pending Ticket input in the replacement body.".into(),
state: JournalEntryState::Open,
source_end: None,
correction_refs: vec![],
},
input.selected_ranges.clone(),
),
(
format!("position:{}", state.generation_id),
JournalAction::UpdatePosition {
current_position: "Delegation received; handling remains waiting. Release this body before replacement; do not replay acknowledged input.".into(),
},
vec![],
),
(
format!("checkpoint:{}", state.generation_id),
JournalAction::Checkpoint { record_operation_ids: vec![record_id] },
input.selected_ranges,
),
] {
let JournalJobResponse::Saved(receipt) = subjektiv_journal::execute(
api,
journal_worker,
JournalJobRequest::Execute {
operation: JournalOperation { request_id, expected_state: token, action, covered_ranges },
},
).unwrap() else {
panic!("saved sequential Journal receipt required")
};
token = receipt.state_token;
}
let completion = subjektiv_journal::finish(api, journal_worker).unwrap();
assert_eq!(completion.state_token, token);
let accepted = api
.accept_backend_job_result(
journal_worker,
&BackendJobResultSubmission {
job_id: job.request.job_id.clone(),
attempt_id: attempt.attempt_id.clone(),
input_digest: attempt.input_digest,
result: serde_json::to_value(completion).unwrap(),
},
)
.unwrap();
assert_eq!(accepted.attempt.state, BackendJobAttemptState::Completed);
wait_for_backend_job_worker_cleanup(
api,
&job.request.job_id,
&attempt.attempt_id,
BackendJobWorkerCleanupState::Completed,
)
.await;
}
fn release_test_assignment_body(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
let response = subjektiv_journal::release_subject(
api,
serde_json::from_value(json!({
"worker":worker, "operation_id":format!("assignment-release-{}", worker.worker_id),
}))
.unwrap(),
)
.unwrap();
assert_eq!(
response.state,
runtime_api::SubjectReleaseState::Disconnected
);
let stopped = api
.runtime
.stop_worker(worker, WorkerLifecycleRequest::default())
.unwrap();
assert_eq!(
stopped.state,
InternalWorkerOperationState::Accepted,
"{:?}",
stopped.diagnostics
);
}
async fn remove_test_assignment_body(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
release_test_assignment_body(api, worker);
let removed = WorkerRemovalService::new(api)
.execute_target_removal(
api.runtime.as_ref(),
worker,
"replace assignment test body",
None,
)
.await
.unwrap();
assert_eq!(removed.status, StatusCode::OK.as_u16(), "{}", removed.body);
}
impl DeterministicExecutionBackend {
fn release_assignment_controller(
&self,
request: &runtime_api::SubjectReleaseRequest,
) -> std::result::Result<runtime_api::SubjectReleaseResponse, String> {
use runtime_api::{SubjectReleaseAction as A, SubjectReleaseState as S};
if !self
.assignment_release_enabled
.load(std::sync::atomic::Ordering::SeqCst)
{
return Err(
"deterministic backend has no actual Subject Controller release support".into(),
);
}
let worker = worker_runtime::identity::WorkerRef::new(request.worker_id);
let execution = self
.contexts
.lock()
.unwrap()
.get(&worker)
.cloned()
.ok_or("Subject execution is absent; no disconnect proof")?;
let mut saved = execution.subject_release.lock().unwrap();
if let Some((prior, state)) = saved.as_ref() {
if *state != S::Cancelled
&& (prior.binding_id != request.binding_id
|| prior.operation_id != request.operation_id
|| prior.workspace_id != request.workspace_id)
{
return Err("another Subject release owns this execution".into());
}
}
let state = match request.action {
A::Reserve => {
if saved.as_ref().is_some_and(|(p, s)| {
p.operation_id == request.operation_id && *s == S::Cancelled
}) {
return Err("cancelled Subject release cannot rearm".into());
}
if execution.state.lock().unwrap().state != protocol::WorkerState::Idle
|| execution.method_rx.lock().unwrap().is_none()
{
return Err("mock execution cannot reserve its live Idle receiver".into());
}
S::Reserved
}
A::Cancel => S::Cancelled,
A::Disconnect => {
if !saved.as_ref().is_some_and(|(p, s)| {
p.operation_id == request.operation_id
&& p.binding_id == request.binding_id
&& matches!(s, S::Reserved | S::Disconnected)
}) {
return Err("Subject disconnect has no exact receiver reservation".into());
}
execution.method_rx.lock().unwrap().take();
S::Disconnected
}
};
*saved = Some((request.clone(), state));
Ok(runtime_api::SubjectReleaseResponse {
binding_id: request.binding_id.clone(),
operation_id: request.operation_id.clone(),
state,
})
}
}
@@ -25,7 +25,7 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
let input = |key: &str| {
serde_json::to_vec(&serde_json::json!({
"operation_id": key,
"principal": {"kind":"workspace_agent","agent_key":"workspace-orchestrator"}
"principal": test_assignment_principal(&api)
}))
.unwrap()
};
@@ -175,6 +175,7 @@ fn tracking_path(ticket: &str) -> String {
}
fn assignment_request(
api: &WorkspaceApi,
identity: &RuntimeIdentityMaterial,
worker: &str,
ticket: &str,
@@ -182,7 +183,7 @@ fn assignment_request(
) -> Request<Body> {
runtime_source_request(identity, Some(worker), "PUT",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"),
serde_json::to_vec(&serde_json::json!({"operation_id":operation,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap())
serde_json::to_vec(&serde_json::json!({"operation_id":operation,"principal":test_assignment_principal(&api)})).unwrap())
}
fn assignment_clear_path(ticket: &str, assignment: &str, operation: &str) -> String {
@@ -191,22 +192,40 @@ fn assignment_clear_path(ticket: &str, assignment: &str, operation: &str) -> Str
)
}
fn replace_tracking_orchestrator(
async fn tracking_embedded_fixture(
root: &Path,
) -> (
WorkspaceApi,
RuntimeIdentityMaterial,
RuntimeWorkerRef,
String,
) {
let (mut api, _) = assignment_test_api_with_recording_backend(root).await;
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID);
let subject_id = ensure_test_assignment_subject(&api);
let source = create_test_assignment_body(&api, EMBEDDED_WORKER_RUNTIME_ID, &subject_id).await;
bootstrap_test_assignment_session(&api, &source);
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new("Subject body replacement"))
.unwrap();
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
(api, identity, source, ticket.id)
}
async fn replace_tracking_orchestrator(
api: &WorkspaceApi,
runtime: &WorkdirlessFixtureRuntime,
source: &RuntimeWorkerRef,
) -> RuntimeWorkerRef {
let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
seed_worker_source_member(api, &replacement.runtime_id, &replacement.worker_id);
let mut summary = runtime.workers.lock().unwrap()[0].clone();
summary.worker = replacement.clone();
runtime.workers.lock().unwrap().push(summary);
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap();
assert_eq!(
find_workspace_orchestrator(api).unwrap().worker,
replacement
);
// Normal Subject release owns writer revocation and the singleton transition.
// This fixture substitutes the Controller receiver, not the lifetime protocol.
remove_test_assignment_body(api, source).await;
let subject_id = ensure_test_assignment_subject(api);
let replacement = create_test_assignment_body(api, &source.runtime_id, &subject_id).await;
bootstrap_test_assignment_session(api, &replacement);
assert_ne!(*source, replacement);
assert_eq!(test_assignment_body(api).unwrap().worker, replacement);
replacement
}
@@ -238,7 +257,13 @@ async fn revoked_runtime_cannot_retry_assign_clear_or_record_tracking() {
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2026-10-11T00:00:00Z' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap();
for request in [
assignment_request(&identity, &source.worker_id, &ticket, "assign-tracking"),
assignment_request(
&api,
&identity,
&source.worker_id,
&ticket,
"assign-tracking",
),
runtime_source_request(
&identity,
Some(&source.worker_id),
@@ -267,7 +292,7 @@ async fn revoked_runtime_cannot_retry_assign_clear_or_record_tracking() {
#[tokio::test]
async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_old_receipts() {
let dir = tempfile::tempdir().unwrap();
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
let (api, identity, source, ticket) = tracking_embedded_fixture(dir.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let app = build_router(api.clone());
let clear_path =
@@ -284,7 +309,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
StatusCode::OK,
)
.await;
let replacement = replace_tracking_orchestrator(&api, &runtime, &source);
let replacement = replace_tracking_orchestrator(&api, &source).await;
for worker in [&source.worker_id, &replacement.worker_id] {
let expected = if worker == &source.worker_id {
StatusCode::UNAUTHORIZED
@@ -293,7 +318,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
};
tracking_response(
app.clone(),
assignment_request(&identity, worker, &ticket, "assign-tracking"),
assignment_request(&api, &identity, worker, &ticket, "assign-tracking"),
expected,
)
.await;
@@ -313,6 +338,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
tracking_response(
app,
assignment_request(
&api,
&identity,
&replacement.worker_id,
&ticket,
@@ -330,19 +356,30 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
}
#[tokio::test]
async fn runtime_orchestrator_tags_without_backend_singleton_do_not_grant_assignment_authority() {
async fn runtime_orchestrator_tags_without_operation_policy_do_not_grant_assignment_authority() {
let dir = tempfile::tempdir().unwrap();
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let forged = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
seed_worker_source_member(&api, &forged.runtime_id, &forged.worker_id);
let mut summary = runtime.workers.lock().unwrap()[0].clone();
summary.worker = forged.clone();
let unprivileged = open_subjektiv_store(&api)
.unwrap()
.create_subject(
crate::subjektiv::SubjectRole::new("unprivileged-assignment-source").unwrap(),
)
.unwrap();
let forged = create_test_assignment_body(&api, &source.runtime_id, &unprivileged.id).await;
// Runtime display metadata cannot mint operation policy. Backend ownership
// remains this real, unprivileged Subject, even if the adapter advertises tags.
let mut workers = runtime.workers.lock().unwrap();
let summary = workers
.iter_mut()
.find(|worker| worker.worker == forged)
.unwrap();
summary.tags = vec!["orchestrator".into(), "singleton".into()];
summary.singleton_key = Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into());
runtime.workers.lock().unwrap().push(summary);
drop(workers);
let app = build_router(api.clone());
for request in [
assignment_request(&identity, &forged.worker_id, &ticket, "forged-assign"),
assignment_request(&api, &identity, &forged.worker_id, &ticket, "forged-assign"),
runtime_source_request(
&identity,
Some(&forged.worker_id),
@@ -376,7 +413,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-account-owner").await;
let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other");
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap();
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":test_assignment_principal(&api)})).unwrap();
let request = |token: &str, method: &str, path: &str, body: Vec<u8>| {
Request::builder()
.method(method)
@@ -459,7 +496,7 @@ async fn assign_tracking(
let value = tracking_response(build_router(api.clone()), runtime_source_request(
identity, Some(&source.worker_id), "PUT",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"),
serde_json::to_vec(&serde_json::json!({ "operation_id": "assign-tracking", "principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" } })).unwrap(),
serde_json::to_vec(&serde_json::json!({ "operation_id": "assign-tracking", "principal": test_assignment_principal(&api) })).unwrap(),
), StatusCode::OK).await;
assert_eq!(
value["assignment"]["assigned_by"],
@@ -755,61 +792,14 @@ async fn tracking_fixture(
api.runtime.register_or_replace(runtime.clone());
let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap();
configure_runtime_request_auth(&mut api, &identity, runtime_id);
let memory = api
.config_store
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
.unwrap();
let reservation = api
.config_store
.reserve_worker_create(
TEST_WORKSPACE_ID,
runtime_id,
"tracking-orchestrator",
"sha256:tracking-orchestrator",
Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY),
&memory,
)
.unwrap();
let mut summary = WorkdirlessFixtureRuntime::worker_summary(
WorkerCreateBinding {
worker_id: reservation.worker_id,
create_fingerprint: reservation.create_fingerprint,
},
&WorkerSpawnRequest {
requested_worker_name: Some("Tracking Orchestrator".into()),
singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()),
intent: WorkerSpawnIntent::WorkspaceCompanion,
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: ProfileSelector::Builtin("builtin:orchestrator".into()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
resolved_execution_grant: None,
},
);
summary.tags = vec!["orchestrator".into(), "singleton".into()];
let source = summary.worker.clone();
runtime.workers.lock().unwrap().push(summary);
seed_worker_source_member(&api, runtime_id, &source.worker_id);
api.config_store
.complete_worker_create_reservation(TEST_WORKSPACE_ID, source.worker_id.parse().unwrap())
.unwrap();
assert_eq!(find_workspace_orchestrator(&api).unwrap().worker, source);
let subject_id = ensure_test_assignment_subject(&api);
let source = create_test_assignment_body(&api, runtime_id, &subject_id).await;
assert_eq!(test_assignment_body(&api).unwrap().worker, source);
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new("Tracking authority"))
.unwrap();
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
(api, runtime, identity, source, ticket.id)
}
@@ -922,7 +912,7 @@ async fn tracking_unassign_and_reassign_keep_results_and_fence_old_assignment()
.await,
result
);
tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"assign-again", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await;
tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"assign-again", "principal":test_assignment_principal(&api)})).unwrap()), StatusCode::OK).await;
let next = api
.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
@@ -1003,7 +993,7 @@ async fn tracking_other_workspace_routes_and_unsigned_reads_are_rejected() {
#[tokio::test]
async fn replaced_orchestrator_cannot_update_or_replay_and_current_replacement_inherits_tracking() {
let dir = tempfile::tempdir().unwrap();
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
let (api, identity, source, ticket) = tracking_embedded_fixture(dir.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let update = tracking_update(&current, "old-orchestrator-result");
let app = build_router(api.clone());
@@ -1013,18 +1003,7 @@ async fn replaced_orchestrator_cannot_update_or_replay_and_current_replacement_i
StatusCode::OK,
)
.await;
let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id);
let mut summary = runtime.workers.lock().unwrap()[0].clone();
summary.worker = replacement.clone();
runtime.workers.lock().unwrap().push(summary);
// Seed the authoritative replacement, not merely a Runtime tag or model role.
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap();
assert_eq!(
find_workspace_orchestrator(&api).unwrap().worker,
replacement
);
let replacement = replace_tracking_orchestrator(&api, &source).await;
tracking_response(
app.clone(),
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update),
@@ -1111,7 +1090,7 @@ async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_r
assert_eq!(replay, current);
let token = seed_test_workspace_owner_token(api.store.as_ref(), "assignment-replay-user").await;
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":test_assignment_principal(&api)})).unwrap();
tracking_response(
build_router(api.clone()),
Request::builder()
@@ -1261,7 +1240,7 @@ async fn assignment_receipt_cannot_be_replayed_under_a_new_runtime_binding() {
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-assignment-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap();
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":test_assignment_principal(&api)})).unwrap();
tracking_response(
build_router(api.clone()),
runtime_source_request(&identity, Some(&source.worker_id), "PUT", &path, body),
@@ -1377,7 +1356,7 @@ async fn released_assignment_query_retains_principal_assign_audit_and_last_track
assert_eq!(released["role"], "orchestrator");
assert_eq!(
released["principal"],
serde_json::json!({"kind":"workspace_agent","agent_key":"workspace-orchestrator"})
serde_json::json!(test_assignment_principal(&api))
);
assert_eq!(released["assigned_by"], current.assigned_by);
assert_eq!(released["assigned_at"], current.assigned_at);
@@ -1407,6 +1386,11 @@ async fn released_assignment_query_retains_principal_assign_audit_and_last_track
.unwrap()
.create(ticket::NewTicket::new("Different Ticket"))
.unwrap();
grant_test_assignment_ticket(
&api,
&ensure_test_assignment_subject(&api),
&other_ticket.id,
);
assert_eq!(
tracking_response(
app,
@@ -1441,7 +1425,7 @@ async fn same_principal_reassignment_retains_old_result_and_projects_replacement
StatusCode::OK,
)
.await;
let assignment = tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"request-again", "expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await;
let assignment = tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"request-again", "expected_assignment_id":current.assignment_id,"principal":test_assignment_principal(&api)})).unwrap()), StatusCode::OK).await;
let next_id = assignment["assignment"]["assignment_id"].as_str().unwrap();
assert_ne!(next_id, current.assignment_id);
let old = tracking_response(
@@ -1494,7 +1478,7 @@ async fn assignment_signed_runtime_without_worker_cannot_self_assign_service_rol
let (api, _, identity, _, ticket) = tracking_fixture(dir.path()).await;
let input = serde_json::json!({
"operation_id": "runtime-only-assign",
"principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" }
"principal": test_assignment_principal(&api)
});
tracking_response(
build_router(api.clone()),
@@ -1595,7 +1579,7 @@ async fn public_assignment_answer_authenticates_source_replays_saved_result_and_
.await;
let assignment_path =
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()),StatusCode::OK).await;
tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":test_assignment_principal(&api)})).unwrap()),StatusCode::OK).await;
let mut stale = body.clone();
stale["operation_key"] = "fresh-stale-answer".into();
tracking_response(app.clone(), account_request(&stale), StatusCode::CONFLICT).await;
@@ -1709,11 +1693,11 @@ async fn ticket_query_principal_and_handling_filter_before_single_item_page_with
.create(ticket::NewTicket::new(format!("Unassigned newer {n}")))
.unwrap();
}
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap();
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":test_assignment_principal(&api),"limit":1})).unwrap();
let found = api.authority.query_tickets(query).unwrap();
assert_eq!(found.items.len(), 1);
assert_eq!(found.items[0].id, ticket);
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap();
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":test_assignment_principal(&api),"limit":1})).unwrap();
assert!(api.authority.query_tickets(query).unwrap().items.is_empty());
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"assigned_principal":{"kind":"worker","runtime_id":source.runtime_id,"worker_id":source.worker_id},"limit":1})).unwrap();
assert!(api.authority.query_tickets(query).unwrap().items.is_empty());
@@ -2062,3 +2046,491 @@ async fn closure_reason_filter_matches_current_judgment_before_paging_and_text_s
reference.id
);
}
// FeatureStorage policy, body singleton ownership and signed Runtime HTTP are real.
// Bodies are Workdir-free. Connected concurrent delegate/tool execution is tested
// separately in subject_authorization_tests, not claimed by this identity test.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn two_subjects_signed_assignment_isolates_current_subjects_and_stale_receipts() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
for branch in ["subject-a-work", "subject-b-work"] {
assert!(
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["branch", branch])
.status()
.unwrap()
.success()
);
}
let (mut api, _execution) = test_api_with_recording_backend(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID);
let token = seed_test_workspace_owner_token(api.store.as_ref(), "parallel-subject-owner").await;
let app = build_router(api.clone());
let mut cases = Vec::new();
for (name, branch) in [
("subject-a", "subject-a-work"),
("subject-b", "subject-b-work"),
] {
let subject = open_subjektiv_store(&api)
.unwrap()
.create_subject_with_behavior(
crate::subjektiv::SubjectRole::new(name).unwrap(),
format!("Own only {name}'s Ticket."),
)
.unwrap();
let mut input = ticket::NewTicket::new(name);
set_test_ticket_target(&mut input, "test-repository", branch);
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
grant_test_assignment_ticket(&api, &subject.id, &ticket.id);
let created = request_json_authenticated(
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
Some(
json!({"runtime_id":EMBEDDED_WORKER_RUNTIME_ID, "display_name":name,
"profile":"builtin:companion", "initial_submit":[],
"feature_connections":{"subjektiv":{"subject_id":subject.id}},
"workdir_attachments":[]}),
),
&token,
StatusCode::OK,
)
.await;
let body = RuntimeWorkerRef::new(
EMBEDDED_WORKER_RUNTIME_ID,
created["worker_id"].as_str().unwrap(),
);
let assign_path = format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/orchestrator",
ticket.id
);
let assign = json!({"operation_id":format!("assign-{name}"),
"principal":{"kind":"subject", "subject_id":subject.id}});
let receipt = tracking_response(
app.clone(),
runtime_source_request(
&identity,
Some(&body.worker_id),
"PUT",
&assign_path,
serde_json::to_vec(&assign).unwrap(),
),
StatusCode::OK,
)
.await;
assert_eq!(receipt["assignment"]["principal"], assign["principal"]);
assert_eq!(receipt["assignment"]["role"], "orchestrator");
let current = api
.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket.id)
.unwrap()
.remove(0);
assert_eq!(
current.tracking.as_ref().unwrap().status,
ticket::TicketAssignmentTrackingStatus::Waiting
);
let links = api
.store
.list_worker_workdir_links(TEST_WORKSPACE_ID, &body)
.unwrap();
assert!(links.is_empty());
let key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap();
assert_eq!(
api.store
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &key)
.unwrap()
.unwrap()
.worker,
body
);
cases.push((subject.id, ticket.id, body, current));
}
assert_ne!(cases[0].0, cases[1].0);
assert_ne!(cases[0].2, cases[1].2);
assert!(
api.store
.current_worker_singleton_owner(
TEST_WORKSPACE_ID,
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
)
.unwrap()
.is_none()
);
let (subject_a, ticket_a, body_a, current_a) = &cases[0];
let (subject_b, ticket_b, body_b, current_b) = &cases[1];
let update_a = tracking_update(current_a, "a-result");
// Exact operation grant is required even for a live body of another Subject.
tracking_response(
app.clone(),
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &update_a),
StatusCode::FORBIDDEN,
)
.await;
grant_test_assignment_ticket(&api, subject_b, ticket_a);
// Granting the operation still cannot impersonate the assigned Subject.
tracking_response(
app.clone(),
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &update_a),
StatusCode::CONFLICT,
)
.await;
let saved = tracking_response(
app.clone(),
signed_tracking_update(&identity, Some(&body_a.worker_id), ticket_a, &update_a),
StatusCode::OK,
)
.await;
assert_eq!(
api.store
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_b, &current_b.assignment_id)
.unwrap()
.unwrap(),
*current_b
);
// Same Subject body may author reassignment if granted, but ownership of A
// immediately moves to B. A's old done receipt remains evidence, not authority.
let reassign = json!({"operation_id":"a-to-b", "expected_assignment_id":current_a.assignment_id,
"principal":{"kind":"subject", "subject_id":subject_b}});
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket_a}/assignments/orchestrator");
tracking_response(
app.clone(),
runtime_source_request(
&identity,
Some(&body_a.worker_id),
"PUT",
&path,
serde_json::to_vec(&reassign).unwrap(),
),
StatusCode::OK,
)
.await;
let next = api
.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_a)
.unwrap()
.remove(0);
assert_ne!(next.assignment_id, current_a.assignment_id);
let fresh = tracking_update(&next, "old-body-new-delegation");
tracking_response(
app.clone(),
signed_tracking_update(&identity, Some(&body_a.worker_id), ticket_a, &fresh),
StatusCode::CONFLICT,
)
.await;
let mut forged_receipt = update_a.clone();
forged_receipt.assignment_id = next.assignment_id.clone();
forged_receipt.expected_last_event_id = next.last_event_id.clone().unwrap();
tracking_response(
app.clone(),
signed_tracking_update(
&identity,
Some(&body_b.worker_id),
ticket_a,
&forged_receipt,
),
StatusCode::CONFLICT,
)
.await;
// A retained self-report receipt is never rewritten to point at the new row.
assert_eq!(saved["assignment_id"], current_a.assignment_id);
tracking_response(
app.clone(),
runtime_source_request(
&identity,
Some(&body_b.worker_id),
"DELETE",
&assignment_clear_path(ticket_a, &next.assignment_id, "clear-a-from-b"),
Vec::new(),
),
StatusCode::OK,
)
.await;
let after_clear = tracking_update(&next, "after-clear");
tracking_response(
app.clone(),
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &after_clear),
StatusCode::CONFLICT,
)
.await;
assert!(
api.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_a)
.unwrap()
.is_empty()
);
assert_eq!(
api.store
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_b, &current_b.assignment_id)
.unwrap()
.unwrap(),
*current_b
);
assert_eq!(
api.authority.ticket(ticket_a).unwrap().state,
server_api::BrowserTicketState::Open
);
assert_eq!(
api.authority.ticket(ticket_b).unwrap().state,
server_api::BrowserTicketState::Open
);
assert_eq!(
open_subjektiv_store(&api)
.unwrap()
.subject(subject_a)
.unwrap()
.unwrap()
.behavior_md,
"Own only subject-a's Ticket."
);
}
#[tokio::test]
async fn subject_operation_policy_revocation_rejects_saved_mutation_receipts_without_releasing_assignment()
{
use server_api::authorization::SubjectPermissionsUpdate;
let dir = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let input = tracking_update(&current, "result-before-policy-revocation");
let app = build_router(api.clone());
let saved = tracking_response(
app.clone(),
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &input),
StatusCode::OK,
)
.await;
let before = api
.store
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, &current.assignment_id)
.unwrap()
.unwrap();
let subject_id = ensure_test_assignment_subject(&api);
let store = open_subjektiv_store(&api).unwrap();
let policy = store.subject_permissions(&subject_id).unwrap().unwrap();
store
.set_subject_permissions(
&subject_id,
&SubjectPermissionsUpdate {
operation_id: "revoke-assignment-operations".into(),
expected_change_id: Some(policy.change_id),
execution: vec![],
delegation: vec![],
},
)
.unwrap();
let answer = json!({"assignment_id":current.assignment_id,
"expected_last_event_id":saved["last_event_id"], "operation_key":"answer-after-revoke", "body":"No authority"});
let answer_path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
for request in [
assignment_request(
&api,
&identity,
&source.worker_id,
&ticket,
"assign-tracking",
),
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &input),
runtime_source_request(
&identity,
Some(&source.worker_id),
"DELETE",
&assignment_clear_path(&ticket, &current.assignment_id, "clear-after-revoke"),
Vec::new(),
),
runtime_source_request(
&identity,
Some(&source.worker_id),
"POST",
&answer_path,
serde_json::to_vec(&answer).unwrap(),
),
] {
tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await;
}
assert_eq!(
api.store
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, &current.assignment_id)
.unwrap()
.unwrap(),
before
);
assert!(before.current);
assert_eq!(
api.store
.list_ticket_assignment_tracking_history(
TEST_WORKSPACE_ID,
&ticket,
&current.assignment_id
)
.unwrap()
.len(),
2
);
assert_eq!(
api.authority.ticket(&ticket).unwrap().state,
server_api::BrowserTicketState::Open
);
}
#[tokio::test]
async fn generic_delegated_worker_authors_with_operation_grants_but_cannot_self_report_subject_handling()
{
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
};
let dir = tempfile::tempdir().unwrap();
let (api, _, identity, body, ticket) = tracking_fixture(dir.path()).await;
let subject_id = ensure_test_assignment_subject(&api);
let store = open_subjektiv_store(&api).unwrap();
let policy = store.subject_permissions(&subject_id).unwrap().unwrap();
let permissions: Vec<_> = [
"ticket.assignment.assign",
"ticket.assignment.clear",
"ticket.assignment.answer",
"ticket.assignment.update",
]
.into_iter()
.map(|operation| ScopedPermission {
operation: operation.into(),
resource: PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: ticket.clone(),
},
})
.collect();
let policy = store
.set_subject_permissions(
&subject_id,
&SubjectPermissionsUpdate {
operation_id: "allow-generic-assignment-author".into(),
expected_change_id: Some(policy.change_id),
execution: policy.execution,
delegation: permissions.clone(),
},
)
.unwrap();
let app = build_router(api.clone());
// Existing Worker control/FeatureStorage grant path, not a Ticket executor
// or a role fixture. No implementation start or T-803 integration is claimed.
let child = tracking_response(app.clone(), runtime_source_request(&identity, Some(&body.worker_id), "POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"),
serde_json::to_vec(&json!({"runtime_id":body.runtime_id, "display_name":"Generic assignment author",
"profile":"builtin:intake", "initial_submit":[], "workdir_attachments":[], "feature_connections":{},
"control_operation_id":"spawn-generic-author",
"execution_grant":{"permissions":permissions, "redelegation":[]}})).unwrap()), StatusCode::OK).await;
let worker_id = child["worker_id"].as_str().unwrap();
assert!(child["worker"]["singleton_key"].is_null());
assert_eq!(child["worker"]["profile"], "builtin:intake");
let saved = tracking_response(
app.clone(),
assignment_request(&api, &identity, worker_id, &ticket, "generic-assign"),
StatusCode::OK,
)
.await;
let current = api
.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
.unwrap()
.remove(0);
assert_eq!(
saved["assignment"]["principal"],
test_assignment_principal(&api)
);
assert_eq!(
saved["assignment"]["assigned_by"],
format!("worker:{}/{worker_id}", body.runtime_id)
);
// Operation policy alone never makes a background Worker the Subject body.
tracking_response(
app.clone(),
signed_tracking_update(
&identity,
Some(worker_id),
&ticket,
&tracking_update(&current, "child-cannot-report"),
),
StatusCode::FORBIDDEN,
)
.await;
let answer = json!({"assignment_id":current.assignment_id,
"expected_last_event_id":current.last_event_id, "operation_key":"generic-answer", "body":"Additional authorized input"});
let answer_path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
let receipt = tracking_response(
app.clone(),
runtime_source_request(
&identity,
Some(worker_id),
"POST",
&answer_path,
serde_json::to_vec(&answer).unwrap(),
),
StatusCode::OK,
)
.await;
assert_eq!(
tracking_response(
app.clone(),
runtime_source_request(
&identity,
Some(worker_id),
"POST",
&answer_path,
serde_json::to_vec(&answer).unwrap()
),
StatusCode::OK
)
.await,
receipt
);
let clear = assignment_clear_path(&ticket, &current.assignment_id, "generic-clear");
tracking_response(
app.clone(),
runtime_source_request(&identity, Some(worker_id), "DELETE", &clear, Vec::new()),
StatusCode::OK,
)
.await;
store
.set_subject_permissions(
&subject_id,
&SubjectPermissionsUpdate {
operation_id: "revoke-generic-author".into(),
expected_change_id: Some(policy.change_id),
execution: policy.execution,
delegation: vec![],
},
)
.unwrap();
for request in [
assignment_request(&api, &identity, worker_id, &ticket, "generic-assign"),
runtime_source_request(&identity, Some(worker_id), "DELETE", &clear, Vec::new()),
runtime_source_request(
&identity,
Some(worker_id),
"POST",
&answer_path,
serde_json::to_vec(&answer).unwrap(),
),
] {
tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await;
}
let retained = api
.store
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, &current.assignment_id)
.unwrap()
.unwrap();
assert!(!retained.current);
assert_eq!(
retained.tracking.unwrap().status,
ticket::TicketAssignmentTrackingStatus::Waiting
);
assert!(
api.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
.unwrap()
.is_empty()
);
}
@@ -25,7 +25,11 @@ async fn result_signed_http(
serde_json::from_slice(&bytes).unwrap()
}
async fn assert_result_diagnostics(api: &WorkspaceApi, recipient: &RuntimeWorkerRef, result_id: &str) {
async fn assert_result_diagnostics(
api: &WorkspaceApi,
recipient: &RuntimeWorkerRef,
result_id: &str,
) {
let page = delivery_http(api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?source_event_id=implementation-result:{result_id}"), None, StatusCode::OK).await;
let rows = page["items"].as_array().unwrap();
assert_eq!(rows.len(), 1);
@@ -43,7 +47,8 @@ async fn assert_result_diagnostics(api: &WorkspaceApi, recipient: &RuntimeWorker
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr() {
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr()
{
let dir = tempfile::tempdir().unwrap();
let (mut api, execution, _) = delivery_real_fixture(dir.path(), false).await;
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
@@ -73,7 +78,15 @@ async fn implementation_result_signed_http_reaches_persistent_orchestrator_witho
let receipt =
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await;
// Another authenticated Worker may report, but cannot replay this Workers receipt.
result_signed_http(&api, &identity, &orchestrator, &ticket.id, &input, StatusCode::CONFLICT).await;
result_signed_http(
&api,
&identity,
&orchestrator,
&ticket.id,
&input,
StatusCode::CONFLICT,
)
.await;
delivery_wait_idle(&api, &orchestrator, 2).await;
assert_eq!(
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await,
@@ -224,9 +237,22 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
})
.unwrap();
// No mutation callback: a diagnostic read must expose, but not route, saved MR sources.
let pending = delivery_http(&api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"), None, StatusCode::OK).await;
let pending = delivery_http(
&api,
"GET",
&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"),
None,
StatusCode::OK,
)
.await;
assert_eq!(pending["items"].as_array().unwrap().len(), 4);
assert!(pending["items"].as_array().unwrap().iter().all(|row| row["stage"] == "awaiting_routing"));
assert!(
pending["items"]
.as_array()
.unwrap()
.iter()
.all(|row| row["stage"] == "awaiting_routing")
);
assert_eq!(pending["source_checks"][0]["origin_present"], true);
assert_eq!(pending["source_checks"][0]["captured"], true);
recover_ticket_notifications(&api);
@@ -325,10 +351,26 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
assert_eq!(execution.attempts.lock().unwrap().len(), attempts);
assert_eq!(delivery_receipts(&api, &orchestrator).len(), 10);
assert_result_diagnostics(&api, &orchestrator, receipt["result_id"].as_str().unwrap()).await;
let diagnostics = delivery_http(&api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"), None, StatusCode::OK).await;
let diagnostics = delivery_http(
&api,
"GET",
&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"),
None,
StatusCode::OK,
)
.await;
let rows = diagnostics["items"].as_array().unwrap();
for kind in ["mr_review_requested", "mr_review", "mr_review_revoked", "mr_review_cancelled", "mr_source_changed"] {
let matching: Vec<_> = rows.iter().filter(|r| r["event_kind"] == kind && r["recipient_role"] == "orchestrator").collect();
for kind in [
"mr_review_requested",
"mr_review",
"mr_review_revoked",
"mr_review_cancelled",
"mr_source_changed",
] {
let matching: Vec<_> = rows
.iter()
.filter(|r| r["event_kind"] == kind && r["recipient_role"] == "orchestrator")
.collect();
assert!(!matching.is_empty(), "missing diagnostic for {kind}");
for row in matching {
assert_eq!(row["stage"], "runtime_accepted");
@@ -337,9 +379,16 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
}
}
for kind in ["mr_review_requested", "mr_review_revoked"] {
assert!(rows.iter().any(|r| r["event_kind"] == kind && r["recipient_role"] == "worker" && r["stage"] == "suppressed" && r["reason"] == "self_notification"));
assert!(rows.iter().any(|r| r["event_kind"] == kind
&& r["recipient_role"] == "worker"
&& r["stage"] == "suppressed"
&& r["reason"] == "self_notification"));
}
assert_eq!(execution.attempts.lock().unwrap().len(), attempts, "diagnostic reads must not dispatch");
assert_eq!(
execution.attempts.lock().unwrap().len(),
attempts,
"diagnostic reads must not dispatch"
);
assert_eq!(
api.authority.ticket(&ticket.id).unwrap().state,
server_api::BrowserTicketState::Open
@@ -715,7 +715,7 @@ async fn worker_control_source_lookup_waits_off_tokio_and_retains_finite_host_ow
}
#[tokio::test(flavor = "current_thread")]
async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free() {
async fn ticket_tracking_authority_and_case_audit_use_local_state_without_runtime_lookup() {
for operation in ["tracking", "case"] {
let fixture = fixture().await;
let ticket = browser_ticket_backend(&fixture.api)
@@ -767,6 +767,9 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
};
let backend = browser_ticket_backend(&fixture.api).unwrap();
let before = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
// Tracking authority and case attribution are now local Subject/assignment
// reads, not observations of a singleton Runtime Worker. Keep a held
// Runtime endpoint to detect an accidental return to network authority.
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let api = fixture.api.clone();
let ticket_id = ticket.id.clone();
@@ -819,8 +822,24 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
},
));
assert!(futures::poll!(&mut decision).is_pending());
let request = observed(reply.entered.take().unwrap()).await.unwrap();
assert!(request.contains("/v1/workers/"), "{operation}: {request}");
let outcome = observed(decision.as_mut()).await;
if operation == "tracking" {
let error = outcome.unwrap_err();
assert!(
error.error.to_string().contains("Subject body"),
"{error:?}"
);
} else {
outcome.unwrap();
}
assert_eq!(leases.load(Ordering::SeqCst), 0);
assert!(
matches!(
reply.entered.as_mut().unwrap().try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
),
"local assignment authority/audit must not observe Runtime"
);
let store = fixture.store.clone();
observed(
fixture
@@ -836,7 +855,6 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
observed(tokio::time::sleep(StdDuration::from_millis(1))).await;
assert!(!reply.upstream_released.load(Ordering::SeqCst));
drop(decision);
assert_eq!(leases.load(Ordering::SeqCst), 1);
reply.finish();
observed(fixture.host_executor.drain()).await.unwrap();
observed(fixture.executor.drain()).await.unwrap();
@@ -848,8 +866,8 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
"tracking still requires live assignment authority"
);
} else {
// Profile-authorized case changes do not depend on a live
// Orchestrator lookup. Its failure only affects audit role context.
// Case changes do not depend on a remote singleton lookup.
// The local assignment-only audit falls back to an ordinary Worker.
assert_eq!(after.meta.workflow_state, ticket::TicketState::Closed);
assert_eq!(
after.events.last().unwrap().attributes["source_actor_role"],
@@ -208,7 +208,8 @@ impl TicketSurface {
.await?;
if name == "ShowTicket" {
let mut page = serde_json::Map::new();
for (tool_field, native_field) in [("event_cursor", "cursor"), ("event_limit", "limit")] {
for (tool_field, native_field) in [("event_cursor", "cursor"), ("event_limit", "limit")]
{
if let Some(value) = input.as_object_mut().unwrap().remove(tool_field) {
page.insert(native_field.into(), value);
}
@@ -451,12 +452,29 @@ async fn reviewer_reads_saved_related_tickets_by_key_and_id_and_pages_their_thre
let related_before = backend.show(related.id.clone().into()).unwrap();
let bound_key = bound.meta.resource_key.as_deref().unwrap();
let related_key = related.resource_key.as_deref().unwrap();
// A relation and Reviewer profile do not grant access to its target.
// Exercise the signed denial first, then grant only the related read.
let transport = ticket_client(&api, identity, &source);
// One client must retain its review binding while following related Tickets.
let client = reviewer_client(transport.clone(), &bound_id);
let surface =
TicketSurface::with_access(dir.path(), client, wip, TicketFeatureAccess::review())
.await;
let denied = surface
.call(
"ShowTicket",
serde_json::json!({"id": related_key}),
ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(denied.to_string().contains("403"), "{denied}");
grant_test_ticket_operations(
&api,
&ensure_test_assignment_subject(&api),
&related.id,
&["ticket.read"],
);
for (reference, expected_key, expected_title, expected_body) in [
(
bound_key,
@@ -684,13 +702,26 @@ async fn implementation_result_tool_and_wip_keep_host_owned_retry_identity_witho
);
assert_eq!(receipt["request"]["body"], input["body"]);
assert_eq!(report_counts(&api), (1, 1));
let queried = surface
let query = serde_json::json!({"ticket":ticket,"result_id":receipt["result_id"]});
let denied = surface
.call(
"TicketImplementationResultQuery",
serde_json::json!({"ticket":ticket,"result_id":receipt["result_id"]}),
context("query"),
query.clone(),
context("query-denied"),
)
.await
.unwrap_err();
assert!(denied.to_string().contains("403"), "{denied}");
// Writing a result does not implicitly authorize reading it.
grant_test_ticket_operations(
&api,
&ensure_test_assignment_subject(&api),
&ticket,
&["ticket.implementation_result.read"],
);
let queried = surface
.call("TicketImplementationResultQuery", query, context("query"))
.await
.unwrap();
assert_eq!(queried, receipt);
let mut changed = input.clone();
+149 -38
View File
@@ -118,6 +118,8 @@ pub use notification_routing::{
mod assignment_tracking;
#[path = "implementation_result.rs"]
mod implementation_result;
#[path = "subject_assignment_migration.rs"]
mod subject_assignment_migration;
pub use assignment_delivery::TicketAssignmentDelivery;
pub use assignment_tracking::TicketOrchestratorAssignmentSourceContext;
pub use ticket::{
@@ -127,7 +129,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 106;
const LATEST_SCHEMA_VERSION: i64 = 107;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -465,6 +467,11 @@ const MIGRATIONS: &[Migration] = &[
name: crate::plugin_catalog::MIGRATION_NAME,
apply: crate::plugin_catalog::migrate,
},
Migration {
version: 107,
name: "Subject Ticket responsibility and current-body notification routing",
apply: migrate_subject_assignments_v106_to_v107,
},
];
// Both schema-103 identities shipped. Keep the alternate implementation and accept
@@ -964,6 +971,9 @@ impl TicketAssignmentRole {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TicketAssignmentPrincipal {
Subject {
subject_id: String,
},
User {
account_id: String,
},
@@ -979,6 +989,7 @@ pub enum TicketAssignmentPrincipal {
impl TicketAssignmentPrincipal {
fn kind(&self) -> &'static str {
match self {
Self::Subject { .. } => "subject",
Self::User { .. } => "user",
Self::Worker { .. } => "worker",
Self::WorkspaceAgent { .. } => "workspace_agent",
@@ -2267,6 +2278,14 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
content: &str,
source: &TicketAssignmentTrackingSourceContext,
) -> Result<String>;
/// Recheck the current target and ACK immediately before sending pinned bytes.
/// None means a positive ACK is already saved, not an unknown acceptance.
fn admit_ticket_assignment_delivery_send(
&self,
workspace_id: &str,
delivery_id: &str,
source: &TicketAssignmentTrackingSourceContext,
) -> Result<Option<String>>;
fn ack_ticket_assignment_delivery(
&self,
workspace_id: &str,
@@ -2297,6 +2316,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
content: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<String>;
fn admit_ticket_worker_notification_send(
&self,
workspace_id: &str,
delivery_id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<Option<String>>;
fn ack_ticket_worker_notification(
&self,
workspace_id: &str,
@@ -9359,6 +9384,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<String> {
notification_routing::prepare_worker(self, workspace_id, delivery_id, content, target)
}
fn admit_ticket_worker_notification_send(
&self,
workspace_id: &str,
delivery_id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<Option<String>> {
notification_routing::admit_worker_send(self, workspace_id, delivery_id, target)
}
fn ack_ticket_worker_notification(
&self,
workspace_id: &str,
@@ -9445,6 +9478,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<String> {
assignment_delivery::prepare_content(self, workspace_id, delivery_id, content, source)
}
fn admit_ticket_assignment_delivery_send(
&self,
workspace_id: &str,
delivery_id: &str,
source: &TicketAssignmentTrackingSourceContext,
) -> Result<Option<String>> {
assignment_delivery::admit_send(self, workspace_id, delivery_id, source)
}
fn ack_ticket_assignment_delivery(
&self,
workspace_id: &str,
@@ -9840,7 +9881,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
runtime_id,
worker_id,
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
TicketAssignmentPrincipal::WorkspaceAgent { .. } => {
TicketAssignmentPrincipal::WorkspaceAgent { .. }
| TicketAssignmentPrincipal::Subject { .. } => {
return Err(Error::TicketAssignmentConflict(
"Workspace agent principal cannot occupy the Worker role".to_string(),
));
@@ -13258,7 +13300,7 @@ impl SqliteWorkspaceStore {
)));
}
if let Some((assignment_id,source))=fence {
assignment_tracking::require_runtime_source(&tx,workspace_id,&source.runtime_id,&source.worker_id,&source.agent_key,&source.authentication_binding_id)?;
assignment_tracking::require_runtime_source(&tx,workspace_id,&source.runtime_id,&source.worker_id,&source.subject_id,&source.authentication_binding_id)?;
assignment_delivery::require_current(&tx,workspace_id,ticket_id,assignment_id)?;
}
let worker = worker_id.map(|id| RuntimeWorkerRef::new(runtime_id, id));
@@ -13382,6 +13424,9 @@ impl SqliteWorkspaceStore {
runtime_id,
worker_id,
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
TicketAssignmentPrincipal::Subject { subject_id } => {
(Some(subject_id.as_str()), None, None)
}
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
(Some(agent_key.as_str()), None, None)
}
@@ -13588,6 +13633,9 @@ fn read_ticket_role_assignment_record(
runtime_id: runtime_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
worker_id: worker_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
},
"subject" => TicketAssignmentPrincipal::Subject {
subject_id: principal_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
},
"workspace_agent" => TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: principal_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
},
@@ -13656,8 +13704,8 @@ fn validate_ticket_assignment_role_principal(
let valid = match role {
TicketAssignmentRole::Orchestrator => matches!(
principal,
TicketAssignmentPrincipal::WorkspaceAgent { agent_key }
if agent_key == "workspace-orchestrator"
TicketAssignmentPrincipal::Subject { subject_id }
if !subject_id.trim().is_empty() && subject_id.len() <= 1024 && !subject_id.chars().any(char::is_control)
),
TicketAssignmentRole::Worker => {
matches!(principal, TicketAssignmentPrincipal::Worker { .. })
@@ -13760,6 +13808,9 @@ fn clear_current_ticket_role_assignment_with_source_in_tx(
runtime_id,
worker_id,
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
TicketAssignmentPrincipal::Subject { subject_id } => {
(Some(subject_id.as_str()), None, None)
}
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
(Some(agent_key.as_str()), None, None)
}
@@ -15361,8 +15412,13 @@ fn migrate_ticket_cases_v90_to_v91(conn: &Connection) -> Result<()> {
// closure reasons and immutable case history. It opens its own transaction.
// Leave the Workspace marker at 90 until our composed constraints commit;
// a failed second phase is resumable through the idempotent domain migrator.
ticket::migrate_sqlite_ticket_schema(conn)
.map_err(|error| Error::Store(format!("Ticket case migration failed: {error}")))?;
let component = conn.unchecked_transaction()?;
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
&component,
ticket::HistoricalTicketSchemaTarget::Cases10,
)
.map_err(|error| Error::Store(format!("Ticket case migration failed: {error}")))?;
component.commit()?;
let invalid: i64 = conn.query_row(
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open', 'closed')",
[],
@@ -15408,8 +15464,11 @@ fn migrate_ticket_tracking_v91_to_v92(conn: &Connection) -> Result<()> {
));
}
let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|error| Error::Store(error.to_string()))?;
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
&tx,
ticket::HistoricalTicketSchemaTarget::Tracking11,
)
.map_err(|error| Error::Store(error.to_string()))?;
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (92, 'Ticket-owned assignment tracking')", [])?;
verify_workspace_resource_constraints(&tx)?;
tx.commit()?;
@@ -15436,8 +15495,11 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
));
}
let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
&tx,
ticket::HistoricalTicketSchemaTarget::Posts12,
)
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
assignment_delivery::install_post_inputs(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')",
@@ -15464,8 +15526,11 @@ fn migrate_notification_routing_v94_to_v95(conn: &Connection) -> Result<()> {
));
}
let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|e| Error::Store(format!("Ticket content facts migration failed: {e}")))?;
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
&tx,
ticket::HistoricalTicketSchemaTarget::Content13,
)
.map_err(|e| Error::Store(format!("Ticket content facts migration failed: {e}")))?;
notification_routing::install(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(95,?1)",
@@ -15737,6 +15802,23 @@ fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_subject_assignments_v106_to_v107(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 106 {
return Err(Error::Store(
"Subject assignment migration requires schema 106".into(),
));
}
let tx = conn.unchecked_transaction()?;
subject_assignment_migration::install(&tx)?;
notification_routing::install_subject_recipients(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,?1)",
["Subject Ticket responsibility and current-body notification routing"],
)?;
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
@@ -15746,6 +15828,8 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
notification_routing::install_business_events(conn)?;
notification_routing::upgrade_result_reports(conn)?;
subject_deletion_store::install_notification_erasure(conn)?;
subject_assignment_migration::install(conn)?;
notification_routing::install_subject_recipients(conn)?;
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
Error::Store(format!(
"fresh Workspace Ticket schema verification failed: {error}"
@@ -16800,6 +16884,7 @@ fn apply_migrations_with_secret_source(
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
crate::plugin_catalog::verify(conn)?;
subject_assignment_migration::verify(conn)?;
verify_workspace_runtime_binding_schema(conn)?;
verify_workspace_runtime_verification_schema(conn)?;
verify_workspace_deletion_schema(conn)?;
@@ -16836,6 +16921,7 @@ mod test_support;
mod tests {
include!("assignment_tracking_tests.rs");
include!("assignment_delivery_tests.rs");
include!("subject_assignment_migration_tests.rs");
include!("notification_routing_tests.rs");
include!("notification_diagnostics_tests.rs");
include!("job_notification_tests.rs");
@@ -19494,6 +19580,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()},
WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()},
WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()},
WorkspaceSchemaMigrationStep {version:107, name:"Subject Ticket responsibility and current-body notification routing".into()},
]
);
@@ -19610,6 +19697,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(104, "Converge Passkey results and Job input retention".to_string()),
(105, "Durable Subject deletion snapshots and Job admission fences".to_string()),
(106, crate::plugin_catalog::MIGRATION_NAME.to_string()),
(107, "Subject Ticket responsibility and current-body notification routing".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -22897,7 +22985,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(103, "Passkey metadata and atomic addition results".into()),
(104, "Converge Passkey results and Job input retention".into()),
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
(107, "Subject Ticket responsibility and current-body notification routing".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}
@@ -23628,8 +23717,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
let first = manual_worker_assignment("first", "first-assignment");
let orchestrator = TicketRoleAssignmentRecord {
role: TicketAssignmentRole::Orchestrator,
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
principal: TicketAssignmentPrincipal::Subject {
subject_id: "fixture-subject".into(),
},
..first.clone()
};
@@ -24713,8 +24802,8 @@ INSERT INTO worker_registry (
ticket_id: ticket.id.clone(),
assignment_id: "orchestrator-1".to_string(),
role: TicketAssignmentRole::Orchestrator,
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".to_string(),
principal: TicketAssignmentPrincipal::Subject {
subject_id: "fixture-subject".to_string(),
},
assigned_by: "user".to_string(),
assigned_at: "2026-09-01T00:00:00Z".to_string(),
@@ -27083,32 +27172,54 @@ INSERT INTO worker_registry (
use crate::backend_job::{JobInputDisposition, JobInputRetirementReason};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("migration.sqlite");
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
// Build the actual retained boundary, never latest DDL relabelled as 102.
prepare_retained_schema(&path, 102);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
// Obtain a canonical pending Job through the current API, then copy only
// columns that existed at the retained boundary into the historical file.
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
seed_resource_job_workspace(&store).await;
let original = resource_job_request("upgrade", "subject", "candidate");
let before = store
.reserve_backend_job("resource-workspace", &original, RESOURCE_JOB_NOW)
.unwrap();
store.with_conn(|conn| {
// This fixture deliberately reconstructs schema 102 from fresh.
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?;
// Remove the newer Subject fence composition, not just its receipt.
let triggers=conn.prepare("SELECT name,tbl_name FROM sqlite_schema WHERE type='trigger' AND tbl_name LIKE 'notification_%' AND name NOT LIKE 'subject_deleted_%' AND sql LIKE '%subject_deletion_jobs%'")?
.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
for (name,table) in triggers {conn.execute_batch(&format!("DROP TRIGGER {name}; CREATE TRIGGER {name} BEFORE DELETE ON {table} BEGIN SELECT RAISE(ABORT,'Notification evidence is immutable'); END;"))?;}
for name in ["subject_deleted_job_insert","subject_deleted_job_result","subject_deleted_attempt_insert","subject_deleted_attempt_result","subject_deleted_worker_reservation","subject_deleted_notification_source","subject_deleted_notification_payload","subject_deleted_notification_send","subject_deletion_worker_prior_insert","subject_deletion_worker_identity_immutable"] {conn.execute_batch(&format!("DROP TRIGGER {name}"))?;}
conn.execute_batch("DROP TABLE subject_deletion_removals; DROP TABLE subject_deletion_allocations; DROP TABLE subject_deletion_jobs; DROP TABLE subject_deletion_workers; DROP TABLE subject_deletions;")?;
conn.execute_batch("DROP TRIGGER backend_job_input_retirement; DROP TRIGGER backend_job_identity_immutable;
ALTER TABLE backend_jobs DROP COLUMN input_disposition;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=106;
CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(())
}).unwrap();
for table in [
"accounts",
"workspaces",
"backend_jobs",
"backend_job_attempts",
] {
let columns = conn
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap()
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
let names = columns.join(",");
let rows = store
.with_conn(|source| {
Ok(fixture_query_rows(
source,
&format!("SELECT {names} FROM {table}"),
))
})
.unwrap();
let placeholders = vec!["?"; columns.len()].join(",");
for row in rows {
conn.execute(
&format!("INSERT INTO {table} ({names}) VALUES ({placeholders})"),
rusqlite::params_from_iter(row),
)
.unwrap();
}
}
drop(store);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 102);
verify_schema_history(&conn, 102).unwrap();
assert!(!column_exists(&conn, "backend_jobs", "input_disposition").unwrap());
conn.execute_batch("CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;").unwrap();
assert!(migrate_job_input_retention_v102_to_v103(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 102);
assert!(!column_exists(&conn, "backend_jobs", "input_disposition").unwrap());
@@ -0,0 +1,120 @@
//! Workspace composition of Ticket-owned Subject responsibility (component 14).
//!
//! Ticket alone owns principal tables/CHECKs, domain assignment guards and the
//! component migration ledger. This adapter composes that migration in the host
//! transaction, then installs only Workspace delivery/start guards. It never edits
//! sqlite_schema, rebuilds Ticket tables, or reinstalls Ticket-owned triggers.
//!
//! There is no trustworthy conversion of `workspace-orchestrator` to a Subject.
//! A current `subjektiv:<id>` body or child execution grant cannot attest ownership
//! of a historical stable-agent assignment. Original principals/current pointers,
//! tracking, inputs, results, receipt fingerprints and source JSON stay evidence,
//! not execution authority. Newly authorized Subject assignment is explicit.
//!
//! No Runtime/FeatureStorage calls, routing, grants, restore or receipt replay
//! occur here. The caller owns the surrounding transaction and Workspace version;
//! on any error it must roll back the whole transaction, including Ticket work.
use super::*;
pub(super) const LEGACY_DIAGNOSTIC: &str = "Legacy Orchestrator identity retained without a trustworthy historical Subject mapping; nonoperational, explicit authorized Subject assignment required. Saved history and receipts do not grant execution authority.";
const HOST_GUARDS: [&str; 2] = [
"ticket_legacy_orchestrator_delivery_no_insert",
"ticket_legacy_orchestrator_start_no_insert",
];
/// Parent may already have composed component 14 in this same transaction; the
/// Ticket entrypoint then verifies its current schema without applying it again.
/// Host guard installation is a single migration, not an operational repair API.
pub(super) fn install(conn: &Connection) -> Result<()> {
if conn.is_autocommit() {
return Err(Error::Store(
"Subject assignment migration requires a caller-owned transaction".into(),
));
}
ticket::migrate_sqlite_ticket_schema_in_transaction(conn).map_err(|error| {
Error::Store(format!(
"Ticket Subject component composition failed: {error}"
))
})?;
conn.execute_batch(HOST_GUARDS_SQL)?;
// Component 14 currently supplies this same diagnostic. Ensure it exists for
// retained legacy rows without duplicating its line or touching their CAS,
// tracking values, historical source JSON, events, inputs or receipts.
conn.execute(
"UPDATE ticket_worker_assignments
SET tracking_migration_diagnostic = CASE
WHEN tracking_migration_diagnostic IS NULL OR tracking_migration_diagnostic=''
THEN ?1 ELSE tracking_migration_diagnostic || char(10) || ?1 END
WHERE role='orchestrator' AND principal_kind!='subject'
AND instr(char(10) || COALESCE(tracking_migration_diagnostic,'') || char(10),
char(10) || ?1 || char(10))=0",
[LEGACY_DIAGNOSTIC],
)?;
verify(conn)
}
/// Read-only verification delegates the owning table/domain-guard contract to
/// Ticket. Workspace verifies only its two complete trigger definitions/targets,
/// followed by host database integrity and FK validation. No repair is performed.
pub(super) fn verify(conn: &Connection) -> Result<()> {
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
Error::Store(format!(
"Ticket Subject schema verification failed: {error}"
))
})?;
let canonical = Connection::open_in_memory()?;
canonical.execute_batch("CREATE TABLE ticket_assignment_deliveries(workspace_id TEXT,assignment_id TEXT);
CREATE TABLE ticket_assignment_operation_fences(workspace_id TEXT,orchestrator_assignment_id TEXT);")?;
canonical.execute_batch(HOST_GUARDS_SQL)?;
for name in HOST_GUARDS {
let expected = trigger_definition(&canonical, name)?;
let actual = trigger_definition(conn, name)?;
if actual != expected {
return Err(Error::Store(format!(
"Workspace Subject guard {name} differs from canonical SQL or target"
)));
}
}
let integrity: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
if integrity != "ok" {
return Err(Error::Store(format!(
"Workspace Subject migration integrity check failed: {integrity}"
)));
}
let violations: i64 =
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})?;
if violations != 0 {
return Err(Error::Store(
"Workspace Subject migration foreign-key violation".into(),
));
}
Ok(())
}
fn trigger_definition(conn: &Connection, name: &str) -> Result<(String, String)> {
conn.query_row(
"SELECT tbl_name,sql FROM main.sqlite_schema WHERE type='trigger' AND name=?1",
[name],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?
.ok_or_else(|| Error::Store(format!("Missing Workspace Subject guard {name}")))
}
const HOST_GUARDS_SQL: &str = r#"
CREATE TRIGGER ticket_legacy_orchestrator_delivery_no_insert
BEFORE INSERT ON ticket_assignment_deliveries
WHEN EXISTS(SELECT 1 FROM ticket_worker_assignments a
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.assignment_id
AND a.role='orchestrator' AND a.principal_kind!='subject')
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
CREATE TRIGGER ticket_legacy_orchestrator_start_no_insert
BEFORE INSERT ON ticket_assignment_operation_fences
WHEN NOT EXISTS(SELECT 1 FROM ticket_worker_assignments a
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.orchestrator_assignment_id
AND a.role='orchestrator' AND a.principal_kind='subject')
BEGIN SELECT RAISE(ABORT,'Ticket startup fence requires Subject responsibility'); END;
"#;
@@ -0,0 +1,842 @@
// Included in store::tests; requires the production module as a store child.
// Component14 owns principal migration and its ledger; these tests exercise the
// real owner API through host composition, not a copied table/guard installer.
// Run: cargo test -p yoi-workspace-server --lib subject_assignment_migration
mod subject_assignment_migration_tests {
use super::*;
use crate::store::subject_assignment_migration::{LEGACY_DIAGNOSTIC, install, verify};
fn fixture(baseline_only: bool) -> (tempfile::TempDir, std::path::PathBuf, Connection) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("subject-assignment.sqlite");
// Real retained migrations, not latest DDL with its version relabelled.
prepare_retained_schema(&path, 106);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
assert_eq!(
conn.query_row(
"SELECT max(version) FROM ticket_schema_migrations",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
13,
"retained106 must be a real pre-Subject component13 fixture; historical host migrations must not consume component14 early"
);
if baseline_only {
conn.execute_batch("DELETE FROM __yoi_schema_migrations;
INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,'workspace schema baseline');").unwrap();
}
seed_workspace(&conn);
for status in ["waiting", "inprogress", "done", "unknown", "released"] {
seed_ticket(&conn, status);
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
VALUES('w',?1,?1,'orchestrator','workspace_agent','workspace-orchestrator','original actor','original time')",[status]).unwrap();
if status == "unknown" {
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=NULL,tracking_migration_diagnostic='Existing ambiguity' WHERE assignment_id=?1",[status]).unwrap();
} else if status != "released" {
conn.execute(
"UPDATE ticket_worker_assignments SET tracking_json=?2 WHERE assignment_id=?1",
params![
status,
format!(
"{{\"status\":\"{status}\", \"result\":\"original result bytes\"}}"
)
],
)
.unwrap();
}
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at)
VALUES('w',?1,'orchestrator',?1,'workspace_agent','workspace-orchestrator','original time')",[status]).unwrap();
conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id)
VALUES('w',?1,'orchestrator',?1,'assigned',?1,'original actor','original time',?1)", [status]).unwrap();
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint,binding_recovery_json)
VALUES('w',?1,'assign',?1,'orchestrator','workspace_agent','workspace-orchestrator',?1,'original time','unchanged-fingerprint','{ \"original\" : true }')",[status]).unwrap();
}
conn.execute_batch("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id)
VALUES('w','released','orchestrator','release-event','unassigned','released','original releaser','release time','clear-op');
DELETE FROM ticket_current_worker_assignments WHERE assignment_id='released';
INSERT INTO typed_ticket_assignment_tracking_operations
VALUES('w','saved-answer','waiting','{ \"old_input\" : true }','{ \"old_source\" : true }','{ \"old_result\" : true }');
INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state,binding_recovery_json)
VALUES('w','pending-start','assign','waiting','worker','worker','r','worker','claim time','pending fingerprint','pending','{ \"reservation\" : true }');
INSERT INTO ticket_assignment_operation_fences
VALUES('w','pending-start','waiting','r','worker','workspace-orchestrator','old-binding','{ \"saved_fence\" : true }');
INSERT INTO ticket_assignment_deliveries VALUES('w','waiting','pending-input','pending-delivery','{ \"payload\" : true }','{ \"source\" : true }');
INSERT INTO ticket_assignment_deliveries VALUES('w','waiting','accepted-input','accepted-delivery','{ \"payload\" : false }','{ \"source\" : false }');
INSERT INTO ticket_assignment_delivery_requests VALUES('w','accepted-delivery','original rendered request');
INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','accepted-delivery','{ \"accepted\" : true }');
INSERT INTO ticket_assignment_content_inputs(workspace_id,ticket_id,assignment_id,after_ticket_event_index,actor,kind)
VALUES('w','waiting','waiting',-1,'original editor','content_edit');
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at)
VALUES('w','waiting','implementation','worker','worker','r','worker','original assigner','original time');
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,runtime_id,worker_id,updated_at)
VALUES('w','waiting','worker','implementation','worker','r','worker','original time');
INSERT INTO ticket_implementation_delegations VALUES('w','implementation','waiting');
INSERT INTO ticket_implementation_results VALUES('w','waiting','result-op','saved-result','r','worker','{ \"body\" : \"original result\", \"kind\" : \"result\" }','result time');
INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose)
SELECT sequence,'w','saved-notification','deliver','original reason','attention' FROM notification_sources WHERE input_event_id='implementation-result:saved-result';
INSERT INTO notification_requests VALUES('w','saved-notification','original common request','{ \"target\" : \"old body\" }');
INSERT INTO notification_acknowledgements VALUES('w','saved-notification','{ \"accepted\" : \"old body\" }');
INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time');
INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap();
// A live body candidate and the old singleton exist simultaneously. A
// migration must not infer stable-agent responsibility from either one.
conn.execute_batch("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at)
VALUES('w','workspace-orchestrator','r','worker',1,'1','2');
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at)
VALUES('w','r','body','Body','normal','1','2');
INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at)
VALUES('w','subjektiv:tempting-subject','r','body',1,'1','2');").unwrap();
(directory, path, conn)
}
fn seed_workspace(conn: &Connection) {
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at)
VALUES('owner','user','owner','Owner','1','2');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at)
VALUES('w','owner','Workspace','active','1','2');
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at)
VALUES('w','r','worker','Worker','normal','1','2');").unwrap();
}
fn seed_ticket(conn: &Connection, id: &str) {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
VALUES('w',?1,?1,'Title','open','task','P2','open',1)",[id]).unwrap();
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
VALUES('w',?1,-1,?2,'comment','Original body')",params![id,format!("{id}:-1")]).unwrap();
}
fn migrate(conn: &Connection) {
let tx = conn.unchecked_transaction().unwrap();
install(&tx).unwrap();
tx.commit().unwrap();
}
// Rowids matter: history pagination uses append ordering. Include all old
// bytes except the one explicitly enriched diagnostic column.
fn evidence(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
let mut rows = Vec::new();
rows.push(fixture_query_rows(
conn,
"SELECT rowid,* FROM ticket_schema_migrations WHERE version<=13 ORDER BY version",
));
rows.push(fixture_query_rows(conn,"SELECT rowid,workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at,tracking_json,last_tracking_event_id,tracking_updated_at FROM ticket_worker_assignments ORDER BY rowid"));
for table in [
"ticket_current_worker_assignments",
"ticket_worker_assignment_events",
"ticket_assignment_operations",
"typed_ticket_assignment_tracking_history",
"typed_ticket_assignment_tracking_operations",
"ticket_assignment_delivery_origins",
"ticket_assignment_deliveries",
"ticket_assignment_delivery_requests",
"ticket_assignment_delivery_acknowledgements",
"ticket_assignment_operation_fences",
"ticket_assignment_content_inputs",
"ticket_assignment_work_releases",
"ticket_implementation_delegations",
"ticket_implementation_results",
"notification_sources",
"notification_decisions",
"notification_requests",
"notification_acknowledgements",
"notification_failures",
"notification_assignment_targets",
"worker_registry",
"worker_singleton_owners",
"worker_create_reservations",
"__yoi_schema_migrations",
// Ticket's owning ledger legitimately appends component14; assert
// its preserved prefix and new receipt separately below.
] {
rows.push(fixture_query_rows(
conn,
&format!("SELECT rowid,* FROM {table} ORDER BY rowid"),
));
}
rows
}
fn component_history(conn: &Connection) -> Vec<Vec<rusqlite::types::Value>> {
fixture_query_rows(
conn,
"SELECT rowid,* FROM ticket_schema_migrations ORDER BY version",
)
}
fn assert_component13(conn: &Connection) {
assert_eq!(
conn.query_row(
"SELECT max(version) FROM ticket_schema_migrations",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
13,
"failed owning/host migration must not retain a component14 receipt"
);
}
fn assert_component14_append(conn: &Connection, before: &[Vec<rusqlite::types::Value>]) {
let history = component_history(conn);
assert_eq!(history.len(), before.len() + 1);
assert_eq!(
&history[..before.len()],
before,
"owning migration preserves all old component receipt bytes/rowids"
);
let latest: (i64,String,String) = conn.query_row("SELECT version,name,applied_at FROM ticket_schema_migrations ORDER BY version DESC LIMIT 1", [],
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?))).unwrap();
assert_eq!(latest.0, 14);
assert_eq!(latest.1, "Subject Ticket responsibility");
assert!(!latest.2.is_empty());
}
fn schema(conn: &Connection) -> Vec<Vec<rusqlite::types::Value>> {
fixture_query_rows(
conn,
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema ORDER BY type,name",
)
}
fn preserved_schema_objects(conn: &Connection) -> Vec<Vec<rusqlite::types::Value>> {
fixture_query_rows(conn, "SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema
WHERE name NOT IN ('ticket_assignment_operations','ticket_current_worker_assignments','ticket_worker_assignments',
'typed_ticket_assignment_orchestrator_principal_insert','typed_ticket_assignment_role_principal_insert')
AND name NOT LIKE 'ticket_subject_%' AND name NOT LIKE 'ticket_legacy_orchestrator_%'
ORDER BY type,name")
}
fn principal_table_contracts(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
let mut contracts = Vec::new();
for table in [
"ticket_assignment_operations",
"ticket_current_worker_assignments",
"ticket_worker_assignments",
] {
contracts.push(fixture_query_rows(
conn,
&format!("SELECT * FROM pragma_table_info('{table}') ORDER BY cid"),
));
contracts.push(fixture_query_rows(
conn,
&format!("SELECT * FROM pragma_foreign_key_list('{table}') ORDER BY id,seq"),
));
contracts.push(fixture_query_rows(
conn,
&format!("SELECT * FROM pragma_index_list('{table}') ORDER BY name"),
));
}
contracts
}
fn cookie(conn: &Connection) -> i64 {
conn.pragma_query_value(None, "schema_version", |row| row.get(0))
.unwrap()
}
fn assert_closed_schema_writes(conn: &Connection) {
assert!(
!conn
.pragma_query_value(None, "writable_schema", |row| row.get::<_, bool>(0))
.unwrap()
);
assert!(
!conn
.pragma_query_value(None, "ignore_check_constraints", |row| row
.get::<_, bool>(0))
.unwrap()
);
assert!(
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))
.unwrap()
);
}
#[test]
fn subject_assignment_migration_preserves_legacy_bytes_rowids_receipts_and_pending_inputs() {
for baseline_only in [false, true] {
let (_directory, path, conn) = fixture(baseline_only);
let before = evidence(&conn);
let component_before = component_history(&conn);
let preserved_schema = preserved_schema_objects(&conn);
let contracts = principal_table_contracts(&conn);
let table_sql_query = "SELECT name,sql FROM sqlite_schema WHERE type='table' AND name IN ('ticket_assignment_operations','ticket_current_worker_assignments','ticket_worker_assignments') ORDER BY name";
let mut table_sql = fixture_query_rows(&conn, table_sql_query);
for row in &mut table_sql {
if let rusqlite::types::Value::Text(sql) = &mut row[1] {
*sql = sql.replace(
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))",
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))",
);
} else {
panic!("table SQL must be text");
}
}
let rootpages = fixture_query_rows(
&conn,
"SELECT name,rootpage FROM sqlite_schema WHERE type='table' ORDER BY name",
);
let content_capture = fixture_query_rows(
&conn,
"SELECT name,sql FROM sqlite_schema WHERE name IN ('ticket_assignment_title_input','ticket_assignment_initial_post_input') ORDER BY name",
);
migrate(&conn);
assert_component14_append(&conn, &component_before);
verify(&conn).unwrap();
assert_eq!(preserved_schema_objects(&conn), preserved_schema);
assert_eq!(principal_table_contracts(&conn), contracts);
assert_eq!(fixture_query_rows(&conn, table_sql_query), table_sql);
assert_eq!(evidence(&conn), before);
assert_eq!(
fixture_query_rows(
&conn,
"SELECT name,rootpage FROM sqlite_schema WHERE type='table' ORDER BY name"
),
rootpages
);
assert_eq!(
fixture_query_rows(
&conn,
"SELECT name,sql FROM sqlite_schema WHERE name IN ('ticket_assignment_title_input','ticket_assignment_initial_post_input') ORDER BY name"
),
content_capture
);
let diagnostics: Vec<(String,String)> = conn.prepare("SELECT assignment_id,tracking_migration_diagnostic FROM ticket_worker_assignments WHERE role='orchestrator' ORDER BY assignment_id").unwrap()
.query_map([],|row|Ok((row.get(0)?,row.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
assert_eq!(diagnostics.len(), 5);
for (id, diagnostic) in diagnostics {
if id == "unknown" {
assert_eq!(
diagnostic,
format!("Existing ambiguity\n{LEGACY_DIAGNOSTIC}")
);
} else {
assert_eq!(diagnostic, LEGACY_DIAGNOSTIC);
}
}
assert_eq!(
conn.query_row(
"SELECT count(*) FROM ticket_worker_assignments WHERE principal_kind='subject'",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
0
);
assert_closed_schema_writes(&conn);
drop(conn);
let reopened = Connection::open(path).unwrap();
configure_sqlite(&reopened).unwrap();
assert_eq!(evidence(&reopened), before);
assert_component14_append(&reopened, &component_before);
assert_eq!(preserved_schema_objects(&reopened), preserved_schema);
assert_eq!(principal_table_contracts(&reopened), contracts);
verify(&reopened).unwrap();
assert_closed_schema_writes(&reopened);
}
}
fn assert_subject_contract(conn: &Connection) {
seed_ticket(conn, "new-ticket");
conn.execute_batch("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint)
VALUES('w','new-op','assign','new-ticket','orchestrator','subject','subject-a','new-assignment','3','subject fingerprint');
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
VALUES('w','new-ticket','new-assignment','orchestrator','subject','subject-a','authorized actor','3');
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at)
VALUES('w','new-ticket','orchestrator','new-assignment','subject','subject-a','3');
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at)
VALUES('w','new-ticket','orchestrator','new-event','assigned','new-assignment','authorized actor','3');").unwrap();
assert_eq!(conn.query_row("SELECT principal_id FROM ticket_current_worker_assignments WHERE assignment_id='new-assignment'",[],|row|row.get::<_,String>(0)).unwrap(),"subject-a");
assert_eq!(conn.query_row("SELECT json_extract(tracking_json,'$.status') FROM ticket_worker_assignments WHERE assignment_id='new-assignment'",[],|row|row.get::<_,String>(0)).unwrap(),"waiting");
for (role, kind, id, runtime, worker) in [
(
"orchestrator",
"workspace_agent",
Some("workspace-orchestrator"),
None,
None,
),
("orchestrator", "subject", Some(" "), None, None),
("orchestrator", "subject", None, None, None),
(
"orchestrator",
"subject",
Some("subject-a"),
Some("r"),
Some("worker"),
),
("orchestrator", "unsupported", Some("subject-a"), None, None),
("worker", "subject", Some("subject-a"), None, None),
("owner", "subject", Some("subject-a"), None, None),
("contributor", "subject", Some("subject-a"), None, None),
] {
assert!(conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at)
VALUES('w','new-ticket','bad',?1,?2,?3,?4,?5,'actor','4')",params![role,kind,id,runtime,worker]).is_err(),"{role}/{kind}/{id:?}");
}
assert!(conn.execute("UPDATE ticket_worker_assignments SET principal_id='other-subject' WHERE assignment_id='new-assignment'",[]).is_err());
assert!(conn.execute("UPDATE ticket_current_worker_assignments SET principal_id='other-subject' WHERE assignment_id='new-assignment'",[]).is_err());
assert!(conn.execute("UPDATE ticket_assignment_operations SET request_fingerprint='changed' WHERE operation_id='new-op'",[]).is_err());
assert_eq!(
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
0
);
}
#[test]
fn subject_assignment_migration_baseline_and_retained_match_fresh_constraints() {
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
for baseline_only in [false, true] {
let (_directory, _path, conn) = fixture(baseline_only);
migrate(&conn);
fresh.with_conn(|new| {
assert!(new.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='ticket_subject_current_insert')",[],|row|row.get::<_,bool>(0))?, "fresh composition must install the Subject assignment contract");
for table in ["ticket_assignment_operations","ticket_worker_assignments","ticket_current_worker_assignments"] {
// Fresh106 installed Ticket core before workspaces existed;
// retained83 has two additional direct Workspace parent FKs.
// Verify both complete known targets, preserve their original
// FKs, and compare all other contract signatures/behavior.
verify(&conn)?;
verify(new)?;
let workspace_parents = |db: &Connection| -> Result<i64> {
db.query_row(&format!("SELECT count(*) FROM pragma_foreign_key_list('{table}') WHERE \"table\"='workspaces'"), [], |row| row.get(0)).map_err(Error::from)
};
assert_eq!(workspace_parents(new)?, 0, "fresh standalone-first preimage: {table}");
assert_eq!(workspace_parents(&conn)?, if table=="ticket_current_worker_assignments" {0} else {1}, "retained Workspace preimage: {table}");
let contract = |db: &Connection| state_contract_schema_signature(db,table).into_iter()
.filter(|row| !row.first().is_some_and(|name| name=="workspaces"))
.collect::<Vec<_>>();
assert_eq!(contract(&conn),contract(new),"{table}");
for db in [&conn,new] {
let ddl:String=db.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",[table],|row|row.get(0))?;
assert!(ddl.contains("CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))"));
}
}
Ok(())
}).unwrap();
assert_subject_contract(&conn);
}
fresh
.with_conn(|conn| {
seed_workspace(conn);
assert_subject_contract(conn);
Ok(())
})
.unwrap();
}
#[test]
fn subject_assignment_migration_legacy_is_readable_but_never_new_authority() {
let (_directory, _path, conn) = fixture(false);
migrate(&conn);
for sql in [
"UPDATE ticket_worker_assignments SET tracking_json='{\"status\":\"inprogress\"}' WHERE assignment_id='waiting'",
"UPDATE ticket_worker_assignments SET last_tracking_event_id='new' WHERE assignment_id='waiting'",
"INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at) VALUES('w','released','orchestrator','released','workspace_agent','workspace-orchestrator','new')",
"INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','waiting','waiting','forged','forged','{\"status\":\"waiting\"}','{}','new')",
"INSERT INTO ticket_assignment_deliveries VALUES('w','waiting','fresh-input','fresh-delivery','{}','{}')",
"INSERT INTO ticket_assignment_operation_fences VALUES('w','waiting','waiting','r','worker','workspace-orchestrator','old-binding','{}')",
"UPDATE ticket_assignment_operations SET request_fingerprint='changed' WHERE operation_id='waiting'",
"UPDATE typed_ticket_assignment_tracking_operations SET result_json='{}' WHERE operation_key='saved-answer'",
] {
assert!(conn.execute(sql, []).is_err(), "{sql}");
}
// Explicit clear may preserve a legacy principal in its new receipt; it
// ends responsibility and does not resurrect an assignment or Worker.
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at)
VALUES('w','explicit-clear','unassign','waiting','orchestrator','workspace_agent','workspace-orchestrator','waiting','new')",[]).unwrap();
assert_subject_contract(&conn);
}
#[test]
fn subject_assignment_migration_failure_rolls_back_schema_cookie_rows_and_allows_retry() {
let (_directory, path, conn) = fixture(false);
conn.execute_batch("CREATE TRIGGER reject_subject_diagnostic BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments BEGIN SELECT RAISE(ABORT,'injected Subject migration failure'); END;").unwrap();
let before_rows = evidence(&conn);
let component_before = component_history(&conn);
let before_schema = schema(&conn);
let before_cookie = cookie(&conn);
{
let tx = conn.unchecked_transaction().unwrap();
let error = install(&tx).unwrap_err();
assert!(
error
.to_string()
.contains("injected Subject migration failure"),
"{error}"
);
assert_closed_schema_writes(&tx);
assert_component13(&tx); // Ticket's savepoint already rolled back.
// Caller rolls back its whole transaction, including ledger writes.
}
assert_eq!(schema(&conn), before_schema);
assert_eq!(cookie(&conn), before_cookie);
assert_component13(&conn);
assert_eq!(evidence(&conn), before_rows);
assert_closed_schema_writes(&conn);
drop(conn);
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
assert_eq!(schema(&conn), before_schema);
assert_eq!(evidence(&conn), before_rows);
conn.execute_batch("DROP TRIGGER reject_subject_diagnostic")
.unwrap();
migrate(&conn);
assert_eq!(evidence(&conn), before_rows);
assert_component14_append(&conn, &component_before);
assert_subject_contract(&conn);
}
#[test]
fn subject_assignment_migration_rejects_autocommit_and_unknown_schema_without_changes() {
let (_directory, _path, conn) = fixture(false);
let before = schema(&conn);
assert!(
install(&conn)
.unwrap_err()
.to_string()
.contains("caller-owned transaction")
);
assert_eq!(schema(&conn), before);
conn.execute_batch("ALTER TABLE ticket_assignment_operations RENAME COLUMN principal_kind TO unexpected_kind;").unwrap();
let before = schema(&conn);
let before_cookie = cookie(&conn);
{
let tx = conn.unchecked_transaction().unwrap();
let error = install(&tx).unwrap_err();
assert!(
error
.to_string()
.contains("Ticket Subject component composition failed"),
"{error}"
);
}
assert_eq!(schema(&conn), before);
assert_eq!(cookie(&conn), before_cookie);
assert_component13(&conn);
assert_closed_schema_writes(&conn);
}
#[test]
fn subject_assignment_migration_rejects_whole_table_drift_and_rolls_back_unknown_schema() {
for table in [
"ticket_assignment_operations",
"ticket_current_worker_assignments",
"ticket_worker_assignments",
] {
let (_directory, path, conn) = fixture(false);
// The formerly matched principal CHECK remains exactly present.
// Unknown layout/default/constraint additions must not be accepted.
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN unexpected_metadata TEXT DEFAULT 'not canonical' CHECK(unexpected_metadata IS NOT NULL);")).unwrap();
let before = schema(&conn);
let rows = evidence(&conn);
let before_cookie = cookie(&conn);
let ddl: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name=?1",
[table],
|row| row.get(0),
)
.unwrap();
assert!(ddl.contains("CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))"));
{
let tx = conn.unchecked_transaction().unwrap();
let error = install(&tx).unwrap_err();
assert!(
error
.to_string()
.contains("Ticket Subject component composition failed")
&& error.to_string().contains(table),
"{error}"
);
assert_closed_schema_writes(&tx);
}
assert_eq!(schema(&conn), before);
assert_eq!(cookie(&conn), before_cookie);
assert_component13(&conn);
assert_eq!(evidence(&conn), rows);
drop(conn);
let reopened = Connection::open(path).unwrap();
configure_sqlite(&reopened).unwrap();
assert_eq!(schema(&reopened), before);
assert_eq!(evidence(&reopened), rows);
assert_closed_schema_writes(&reopened);
}
}
#[test]
fn subject_assignment_migration_rejects_weakened_principal_preimage_without_repair() {
let (_directory, _path, conn) = fixture(false);
conn.execute_batch("DROP TRIGGER typed_ticket_assignment_orchestrator_principal_insert;
CREATE TRIGGER typed_ticket_assignment_orchestrator_principal_insert BEFORE INSERT ON ticket_worker_assignments BEGIN SELECT 1; END;").unwrap();
let before = schema(&conn);
let rows = evidence(&conn);
{
let tx = conn.unchecked_transaction().unwrap();
let error = install(&tx).unwrap_err();
assert!(
error
.to_string()
.contains("typed_ticket_assignment_orchestrator_principal_insert")
&& error.to_string().contains("canonical"),
"{error}"
);
}
assert_eq!(schema(&conn), before);
assert_eq!(evidence(&conn), rows);
assert_component13(&conn);
assert_closed_schema_writes(&conn);
}
#[test]
fn subject_assignment_migration_readonly_owner_failure_preserves_settings_and_retries() {
let (_directory, _path, conn) = fixture(false);
let before = schema(&conn);
let rows = evidence(&conn);
let before_cookie = cookie(&conn);
{
let tx = conn.unchecked_transaction().unwrap();
// Failure at the delegated owning migration boundary must not leak
// connection settings or install host guards. Ticket owns injection
// details inside its principal-table migration, not this adapter.
tx.pragma_update(None, "query_only", true).unwrap();
let error = install(&tx).unwrap_err();
assert!(error.to_string().contains("readonly"), "{error}");
assert_closed_schema_writes(&tx);
tx.pragma_update(None, "query_only", false).unwrap();
}
assert_eq!(schema(&conn), before);
assert_eq!(cookie(&conn), before_cookie);
assert_component13(&conn);
assert_eq!(evidence(&conn), rows);
assert_closed_schema_writes(&conn);
migrate(&conn);
verify(&conn).unwrap();
assert_subject_contract(&conn);
}
#[test]
fn subject_assignment_migration_verifier_checks_every_complete_trigger_and_table() {
let (_directory, _path, conn) = fixture(false);
migrate(&conn);
verify(&conn).unwrap();
let guards: Vec<(String, String)> = conn.prepare("SELECT name,tbl_name FROM sqlite_schema
WHERE type='trigger' AND (name LIKE 'ticket_subject_%' OR name LIKE 'ticket_legacy_orchestrator_%'
OR name IN ('typed_ticket_assignment_orchestrator_principal_insert','typed_ticket_assignment_role_principal_insert'))
ORDER BY name").unwrap().query_map([], |row| Ok((row.get(0)?,row.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
assert_eq!(guards.len(), 10);
for (name, table) in guards {
conn.execute_batch("SAVEPOINT weakened_guard").unwrap();
conn.execute_batch(&format!("DROP TRIGGER {name}; CREATE TRIGGER {name} BEFORE INSERT ON {table} BEGIN SELECT 1; END;")).unwrap();
let drift = schema(&conn);
let error = verify(&conn).unwrap_err();
assert!(
error.to_string().contains(&name),
"owning or host verifier must reject {name}: {error}"
);
if name == "ticket_legacy_orchestrator_delivery_no_insert"
|| name == "ticket_legacy_orchestrator_start_no_insert"
{
assert!(
error.to_string().contains("Workspace Subject guard"),
"{error}"
);
} else {
assert!(
error
.to_string()
.contains("Ticket Subject schema verification failed"),
"{error}"
);
}
assert_eq!(schema(&conn), drift, "verification must not repair a guard");
conn.execute_batch("ROLLBACK TO weakened_guard; RELEASE weakened_guard")
.unwrap();
verify(&conn).unwrap();
}
for table in [
"ticket_assignment_operations",
"ticket_current_worker_assignments",
"ticket_worker_assignments",
] {
conn.execute_batch("SAVEPOINT weakened_table").unwrap();
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN unexpected TEXT;"))
.unwrap();
let error = verify(&conn).unwrap_err();
assert!(
error
.to_string()
.contains("Ticket Subject schema verification failed")
&& error.to_string().contains(table),
"{error}"
);
conn.execute_batch("ROLLBACK TO weakened_table; RELEASE weakened_table")
.unwrap();
}
for setting in ["writable_schema", "ignore_check_constraints"] {
conn.pragma_update(None, setting, true).unwrap();
let error = verify(&conn).unwrap_err();
assert!(
error
.to_string()
.contains(&format!("requires {setting} OFF")),
"{error}"
);
conn.pragma_update(None, setting, false).unwrap();
}
verify(&conn).unwrap();
}
#[test]
fn subject_assignment_migration_startup_rejects_guard_drift_without_repair() {
for guard in [
"ticket_legacy_orchestrator_delivery_no_insert",
"ticket_legacy_orchestrator_start_no_insert",
] {
let (_directory, path, conn) = fixture(false);
apply_migrations(&conn).unwrap();
conn.execute_batch(&format!("DROP TRIGGER {guard}"))
.unwrap();
let before_schema = schema(&conn);
let before_rows = evidence(&conn);
let before_cookie = cookie(&conn);
let before_components = component_history(&conn);
drop(conn);
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
let error = apply_migrations(&conn).unwrap_err();
assert!(error.to_string().contains(guard), "{error}");
assert_eq!(schema(&conn), before_schema);
assert_eq!(evidence(&conn), before_rows);
assert_eq!(cookie(&conn), before_cookie);
assert_eq!(component_history(&conn), before_components);
assert_closed_schema_writes(&conn);
}
}
#[test]
fn subject_assignment_migration_host_failure_rolls_back_owning_component_and_retries() {
let (_directory, path, conn) = fixture(false);
// Force failure on the SECOND host guard, after Ticket14 and the first
// host guard have succeeded. The outer host transaction owns rollback.
conn.execute_batch(
"CREATE TRIGGER ticket_legacy_orchestrator_start_no_insert
BEFORE INSERT ON ticket_assignment_operation_fences BEGIN SELECT 1; END;",
)
.unwrap();
let before_schema = schema(&conn);
let before_rows = evidence(&conn);
let component_before = component_history(&conn);
let before_cookie = cookie(&conn);
{
let tx = conn.unchecked_transaction().unwrap();
let error = install(&tx).unwrap_err();
assert!(error.to_string().contains("already exists"), "{error}");
assert_component14_append(&tx, &component_before);
assert!(tx.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='ticket_legacy_orchestrator_delivery_no_insert')", [], |row| row.get::<_,bool>(0)).unwrap());
assert!(!tx.is_autocommit());
assert_closed_schema_writes(&tx);
}
assert_eq!(schema(&conn), before_schema);
assert_eq!(evidence(&conn), before_rows);
assert_eq!(component_history(&conn), component_before);
assert_eq!(cookie(&conn), before_cookie);
assert_component13(&conn);
drop(conn);
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
assert_eq!(schema(&conn), before_schema);
assert_eq!(component_history(&conn), component_before);
conn.execute_batch("DROP TRIGGER ticket_legacy_orchestrator_start_no_insert")
.unwrap();
migrate(&conn);
assert_component14_append(&conn, &component_before);
verify(&conn).unwrap();
assert_subject_contract(&conn);
}
#[test]
fn subject_assignment_migration_precomposed_owner_is_not_reinstalled_or_rediagnosed() {
let (_directory, _path, conn) = fixture(false);
let old_components = component_history(&conn);
{
let tx = conn.unchecked_transaction().unwrap();
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx).unwrap();
tx.commit().unwrap();
}
assert_component14_append(&conn, &old_components);
ticket::verify_sqlite_ticket_schema(&conn).unwrap();
// The host must not append the owning diagnostic again or issue even a
// redundant diagnostic UPDATE for any already-diagnosed legacy row.
conn.execute_batch(
"CREATE TRIGGER forbid_duplicate_subject_diagnostic
BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments
BEGIN SELECT RAISE(ABORT,'host duplicated owning diagnostic'); END;",
)
.unwrap();
let owner_schema = schema(&conn);
let owner_assignments = fixture_query_rows(
&conn,
"SELECT rowid,* FROM ticket_worker_assignments ORDER BY rowid",
);
let owner_receipts = component_history(&conn);
let before_rows = evidence(&conn);
let before_cookie = cookie(&conn);
migrate(&conn);
assert_eq!(component_history(&conn), owner_receipts);
assert_eq!(
fixture_query_rows(
&conn,
"SELECT rowid,* FROM ticket_worker_assignments ORDER BY rowid"
),
owner_assignments
);
assert_eq!(evidence(&conn), before_rows);
let after_without_host = fixture_query_rows(&conn,"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema
WHERE name NOT IN ('ticket_legacy_orchestrator_delivery_no_insert','ticket_legacy_orchestrator_start_no_insert') ORDER BY type,name");
assert_eq!(
after_without_host, owner_schema,
"host must not rebuild tables or replace any owning guard"
);
assert_eq!(
cookie(&conn),
before_cookie + 2,
"only the two host CREATE TRIGGER statements change the schema"
);
verify(&conn).unwrap();
}
#[test]
fn subject_assignment_migration_reloads_prepared_constraints_and_reopen_cache() {
let (_directory, path, conn) = fixture(false);
let mut cached = conn.prepare_cached("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint)
VALUES('w','cached-subject','assign','waiting','orchestrator','subject','subject-a','cached-assignment','3','cached fingerprint')").unwrap();
assert!(cached.execute([]).is_err(), "old CHECK rejects Subject");
{
let tx = conn.unchecked_transaction().unwrap();
install(&tx).unwrap();
// Drop the whole migration transaction; a prepared statement must
// not retain broadened constraints after rollback.
}
assert_component13(&conn);
assert!(
cached.execute([]).is_err(),
"rollback restores old CHECK and guards"
);
migrate(&conn);
cached.execute([]).unwrap();
drop(cached);
verify(&conn).unwrap();
drop(conn);
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
verify(&conn).unwrap();
assert_eq!(conn.query_row("SELECT request_fingerprint FROM ticket_assignment_operations WHERE operation_id='cached-subject'", [], |row| row.get::<_, String>(0)).unwrap(), "cached fingerprint");
assert_subject_contract(&conn);
}
}
@@ -211,6 +211,10 @@ fn page_sql(
if let Some(principal) = &query.assigned_principal {
use ticket::TicketAssignmentPrincipalSnapshot::*;
terms.push(match principal {
Subject { subject_id } => format!(
"a.principal_kind='subject' AND a.principal_id={}",
bind(subject_id.clone())
),
WorkspaceAgent { agent_key } => format!(
"a.principal_kind='workspace_agent' AND a.principal_id={}",
bind(agent_key.clone())
@@ -342,6 +346,7 @@ fn enrich_page(
let mut statement = conn.prepare(&format!("SELECT a.ticket_id,json_object(
'workspace_id',a.workspace_id,'ticket_id',a.ticket_id,'assignment_id',a.assignment_id,'role',a.role,
'principal',json(CASE a.principal_kind
WHEN 'subject' THEN json_object('kind','subject','subject_id',a.principal_id)
WHEN 'workspace_agent' THEN json_object('kind','workspace_agent','agent_key',a.principal_id)
WHEN 'user' THEN json_object('kind','user','account_id',a.principal_id)
WHEN 'worker' THEN json_object('kind','worker','runtime_id',a.runtime_id,'worker_id',a.worker_id) END),
@@ -78,12 +78,12 @@ impl Fixture {
fn assign(&self, id: &str, assignment: &str, status: Option<&str>, current: bool) {
self.authority.store.with_conn(|conn| {
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
VALUES('w',?1,?2,'orchestrator','workspace_agent','workspace-orchestrator','owner-account','1')",params![id,assignment])?;
VALUES('w',?1,?2,'orchestrator','subject','subject-a','owner-account','1')",params![id,assignment])?;
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?1,tracking_updated_at=?2 WHERE workspace_id='w' AND assignment_id=?3",
params![status.map(|s| serde_json::json!({"status":s,"waiting_reason":null,"progress":null,"next_action":null,"result":null}).to_string()),status.map(|_|"1"),assignment])?;
if current {
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at)
VALUES('w',?1,'orchestrator',?2,'workspace_agent','workspace-orchestrator','1')",params![id,assignment])?;
VALUES('w',?1,'orchestrator',?2,'subject','subject-a','1')",params![id,assignment])?;
}
Ok(())
}).unwrap();
@@ -100,9 +100,9 @@ impl Fixture {
}).unwrap();
}
}
fn agent() -> ticket::TicketAssignmentPrincipalSnapshot {
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
fn assigned_subject() -> ticket::TicketAssignmentPrincipalSnapshot {
ticket::TicketAssignmentPrincipalSnapshot::Subject {
subject_id: "subject-a".into(),
}
}
fn ids(response: &TicketBrowseQueryResponse) -> Vec<String> {
@@ -264,7 +264,7 @@ async fn ticket_browse_all_filters_are_applied_before_limit_and_batch_fields_mat
query: Some("NEEDLE".into()),
states: vec![TicketState::Closed],
tag_ids: vec!["b".into(), "a".into(), "a".into()],
assigned_principal: Some(agent()),
assigned_principal: Some(assigned_subject()),
handling_status: Some(ticket::TicketAssignmentTrackingStatus::Done),
closure_reason: Some(TicketClosureReason::Other("custom reason".into())),
limit: Some(1),
@@ -372,7 +372,7 @@ async fn ticket_browse_assignment_current_missing_unknown_and_cleared_semantics(
f.authority.ticket_assignment_tracking(&unknown).unwrap()
);
let mut assigned = ids(&f.run(TicketBrowseQueryRequest {
assigned_principal: Some(agent()),
assigned_principal: Some(assigned_subject()),
..Default::default()
}));
assigned.sort();
@@ -83,7 +83,7 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references);
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
assert_eq!(markers.len(),old_markers.len()+2);
assert_eq!(markers.len(),old_markers.len()+3);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_content_changes",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent content change facts");
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs",[],|r|r.get::<_,i64>(0))?,0,"migration does not turn historical edits into new inputs");
let initial: (String,Option<String>,Option<String>,String,String) = conn.query_row("SELECT e.body,t.created_by,e.author,e.kind,e.event_ref FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='authored' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?;
@@ -266,7 +266,7 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,13);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,14);
assert_eq!(fixture_auth_rows(conn),auth_before);
for (table, rows) in preserved_tables.iter().zip(&preserved) {
let mut migrated = stored_fixture_rows(conn, table);
@@ -500,7 +500,7 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history
[],
|row| row.get::<_, i64>(0)
)?,
13
14
);
assert_eq!(
stored_fixture_rows(conn, "typed_ticket_case_history"),
@@ -1049,8 +1049,8 @@ mod tests {
ticket_id: initial.meta.id.clone(),
assignment_id: assignment_id.clone(),
role: crate::store::TicketAssignmentRole::Orchestrator,
principal: crate::store::TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
principal: crate::store::TicketAssignmentPrincipal::Subject {
subject_id: "deletion-fixture-subject".into(),
},
assigned_by: "owner".into(),
assigned_at: "2026-01-01T00:00:00Z".into(),
@@ -1094,7 +1094,7 @@ mod tests {
actor: "orchestrator".into(),
runtime_id: "fixture-runtime".into(),
worker_id: "fixture-worker".into(),
agent_key: "workspace-orchestrator".into(),
subject_id: "deletion-fixture-subject".into(),
authentication_binding_id: "fixture-binding".into(),
},
)?;
+34 -1
View File
@@ -12331,6 +12331,23 @@
},
"TicketAssignmentPrincipal": {
"oneOf": [
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "subject",
"type": "string"
},
"subject_id": {
"type": "string"
}
},
"required": [
"kind",
"subject_id"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
@@ -12391,6 +12408,22 @@
"TicketAssignmentPrincipalSnapshot": {
"description": "Immutable principal as stored at Assign, not live Worker availability.",
"oneOf": [
{
"properties": {
"kind": {
"const": "subject",
"type": "string"
},
"subject_id": {
"type": "string"
}
},
"required": [
"kind",
"subject_id"
],
"type": "object"
},
{
"properties": {
"account_id": {
@@ -19062,7 +19095,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f"
"x-yoi-source-digest": "sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38"
},
"openapi": "3.1.0",
"paths": {
+4 -4
View File
@@ -9,7 +9,7 @@
states = {
implement = {
instructions = "This is an optional code recipe for requested Git publication and independent review, not a universal Ticket gate. If the user requests no review or return without conclusion, honor that bounded request and do not treat this recipe as authority to conclude the Ticket. Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. Use local work-branch switching, `git add`, `git commit`, and publication only within the request and existing grants; this recipe does not override an explicit no-commit/no-push instruction or expand resource authority. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority.";
instructions = "This is an optional code recipe for requested Git publication and independent review, not a universal Ticket gate. If the user requests no review or return without conclusion, honor that bounded request and do not treat this recipe as authority to conclude the Ticket. Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. Use local work-branch switching, `git add`, `git commit`, and publication only within the request and existing grants; this recipe does not override an explicit no-commit/no-push instruction or expand resource authority. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and requires separate integration authority for the assigned Subject. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority.";
transitions = {
review = {
target = "review";
@@ -43,17 +43,17 @@
};
complete = {
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using the Ticket, stable operation_key, kind, bounded body and optional retained MR/source evidence. Backend authenticates an active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. Retry unknown outcomes only with the same key and full input.";
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using the Ticket, stable operation_key, kind, bounded body and optional retained MR/source evidence. Backend authenticates an active Workspace Worker; no current or pinned implementation assignment or Subject assignment is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the assigned Subject refreshes readiness/integration evidence against the current target, using its authorized current body. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. Retry unknown outcomes only with the same key and full input.";
transitions = {
completed = {
target = "done";
condition = "The exact approved Merge Request source ref and implementation evidence have been durably handed off to the Orchestrator. A Flow state or prose report alone is never Ticket completion authority.";
condition = "The exact approved Merge Request source ref and implementation evidence have been durably handed off to the assigned Subject. A Flow state or prose report alone is never Ticket completion authority.";
};
};
};
done = {
instructions = "The reviewed candidate and its evidence have been handed off. The Orchestrator decides which changes to adopt, adapt, or reject and validates the resulting combined state; completion of this recipe does not require adoption of the whole MR or use of `CompleteMergeRequest`. Flow terminal state only reflects this handoff, not Ticket closure, assignment handling done, work release, unassignment, or Worker stop. Preserve the evidence without claiming an integration or delivery decision that the Orchestrator has not made.";
instructions = "The reviewed candidate and its evidence have been handed off. The assigned Subject decides which changes to adopt, adapt, or reject and validates the resulting combined state, subject to current assignment/body ownership and operation/resource grants; neither this recipe nor the compatibility orchestrator role/Profile name grants that authority. Completion of this recipe does not require adoption of the whole MR or use of `CompleteMergeRequest`. Flow terminal state only reflects this handoff, not Ticket closure, assignment handling done, work release, unassignment, or Worker stop. Preserve the evidence without claiming an integration or delivery decision that the assigned Subject has not made. A background Worker's handoff does not self-report the Subject's handling or transfer its durable assignment, and retained receipts never authorize an old body or released/replaced assignee to start new work.";
terminal = true;
};
};
+1 -1
View File
@@ -1,6 +1,6 @@
import "./base.dcdl" // {
slug = "coder";
description = "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.";
description = "Bounded code implementation for a Ticket's assigned Subject; Profile selection confers no assignee/body authority. Optional Git/MR review recipe and no universal Ticket conclusion gate.";
scope = "workspace_write";
model = { ref = "codex-oauth/gpt-6.1-sol"; };
engine = {
+1 -1
View File
@@ -1,6 +1,6 @@
import "./base.dcdl" // {
slug = "intake";
description = "Ticket intake profile.";
description = "Ticket authoring and clarification; handoff does not assign a Subject or start execution.";
scope = "workspace_write";
engine = { instruction = "role.intake"; };
+2 -1
View File
@@ -1,6 +1,7 @@
import "./base.dcdl" // {
slug = "orchestrator";
description = "Owns outcome delivery, selection and validation of candidate changes, and useful delegation; MR workflows are optional.";
# 互換slugを維持するが、Subject担当・本体所有・操作権限はこのProfileから付与しない。
description = "Ticket outcome coordination for an assigned Subject, with candidate selection, validation and bounded delegation; no fixed assignee or privileged role identity. MR workflows are optional.";
scope = "workspace_write";
engine = { instruction = "role.orchestrator"; };
+1 -1
View File
@@ -1,6 +1,6 @@
import "./default.dcdl" // {
slug = "ticket-worker";
description = "General intent-led Ticket work with optional result recording and authorized conclusion decisions.";
description = "Intent-led Ticket work with bounded result reporting and authorized conclusion decisions; the Worker is not the durable Subject assignee.";
engine = {
instruction = "role.ticket_worker";
};
+7 -3
View File
@@ -12,12 +12,16 @@ Ticket cases have only `open` (still handled) and `closed` (handling ended for n
Classification tags use stable tag IDs, not display names, for mutations and filters. Use the shared Workspace tag tools for creation/list/rename/delete and Ticket tag-set changes with their observed operation identities; names such as `ready` never grant start permission. Tag-set CAS compares `expected_tag_ids` with the observed full ID set; tag rename/delete CAS compares `expected_last_operation_key` with the observed tag operation identity. Classification is separate from a close reason and never closes/reopens or delegates a Ticket.
Read the current Orchestrator delegation with `TicketAssignmentTrackingQuery`; enumerate retained assignment IDs with bounded `TicketAssignmentsQuery`, then read a selected row and its `TicketAssignmentTrackingHistory`. Use `TicketAssignmentDiagnostics` for independent bounded delivery/input and start evidence; its separate cursors and explicit unknown/history fields are not a handling status. `QueryTicket` can filter the exact current `assigned_principal`, self-reported `handling_status`, and current closed-case `closure_reason`. Closure reason is distinct from tag IDs; retained/reopened reasons are history, and Worker status is never inferred. Each immutable `assignment_id` denotes this particular delegation to the stable Workspace Orchestrator principal, not a concrete Runtime Worker. Assign is delegation and initial judgment input; no ready step or separate Queue is required. `waiting`, `inprogress`, and `done` are the Orchestrator's self-reported handling, never Ticket case states, delivery/acceptance or Worker running/idle/stop. Record waiting reason, progress, next action and result through `TicketAssignmentTrackingUpdate` when authorized, not just conversation memory. Waiting must identify what is needed next; research returned to a human can be `open` + handling `done`. Respect an explicit user approval condition; do not add a universal human/MR gate. Done is not Backend certification of satisfaction, third-party approval, merge, actual work release, or Worker stop.
A Ticket's durable assignee is an explicitly selected Subject, represented by `{kind: "subject", subject_id: "<subject ID>"}`; `subject_id` is a string. Any permitted Subject can be selected: there is no fixed Workspace-wide assignee or required Orchestrator singleton. Keep the assigned Subject distinct from its current body Worker, background implementation Workers/attempts, and Repository/branch/Workdir resources. Body stop, replacement, or restore does not reassign the Ticket. Different Subjects may handle different Tickets on separate branches and Workdirs in parallel; preserve per-Subject body ownership and actual resource-conflict checks rather than imposing Workspace-wide serialization. Assignment, Profile/role names, Feature activation, and branch separation do not grant operation or resource authority. Backend authenticates the acting Worker and checks effective grants, current Subject/body ownership where required, and Ticket-specific assignment/target conditions.
Use `TicketOrchestratorAssign` for explicit initial delegation or reissue, with target principal, observed `expected_assignment_id` (explicit null only when unassigned), and stable `operation_id`. Query first; target principal is not the actor or an authority claim. The usual target is `{kind: "workspace_agent", agent_key: "workspace-orchestrator"}`; Backend validates role/principal and actual authentication. Use `TicketOrchestratorClear` only for explicit release of the observed assignment with its ID and operation_id, not automatically on done/closed. Neither operation starts or stops a Worker. Exact retry retains the same operation_id and full input; conflict or unknown outcome must not silently generate a new key. Reread current tracking before any subsequent mutation.
Read the current Subject assignment with `TicketAssignmentTrackingQuery`; enumerate retained assignment IDs with bounded `TicketAssignmentsQuery`, then read a selected row and its `TicketAssignmentTrackingHistory`. Use `TicketAssignmentDiagnostics` for independent bounded delivery/input and start evidence; its separate cursors and explicit unknown/history fields are not a handling status. `QueryTicket` can filter the exact current Subject `assigned_principal`, self-reported `handling_status`, and current closed-case `closure_reason`. Closure reason is distinct from tag IDs; retained/reopened reasons are history, and Worker status is never inferred. Each immutable `assignment_id` denotes this particular assignment period to the Subject, not a concrete Runtime Worker. Assign is delegation and initial judgment input; no ready step or separate Queue is required. `waiting`, `inprogress`, and `done` are the assigned Subject's self-reported handling, never Ticket case states, delivery/acceptance or Worker running/idle/stop. Record waiting reason, progress, next action and result through `TicketAssignmentTrackingUpdate` only when authorized to act for the current assigned Subject, not just in conversation memory. Waiting must identify what is needed next; research returned to a human can be `open` + handling `done`. Respect an explicit user approval condition; do not add a universal human/MR gate. Done is not Backend certification of satisfaction, third-party approval, merge, actual work release, or Worker stop.
Use `TicketOrchestratorAssign` for explicit initial assignment, reassignment or reissue to the selected Subject principal, with observed `expected_assignment_id` (explicit null only when unassigned) and stable `operation_id`. Query first; target principal is not the actor or an authority claim. Use `{kind: "subject", subject_id: "<subject ID>"}` for new assignments. Legacy User, Worker and WorkspaceAgent principals (`user`, `worker`, `workspace_agent`) remain readable for historical/audit evidence only; do not use them for new assignments, infer a Subject mapping, or silently replace an unmapped legacy assignee. Report unresolved migration/identity diagnostics instead. `TicketOrchestratorAssign`, `TicketOrchestratorClear`, the `orchestrator` role/Profile spelling, and `orchestrator_assignment_id` are retained compatibility names, not a privileged role or fixed target identity. Use `TicketOrchestratorClear` only for explicit release of the observed assignment with its ID and operation_id, not automatically on done/closed. Neither operation starts or stops a Worker. Exact retry retains the same operation_id and full input; conflict or unknown outcome must not silently generate a new key. Reread current tracking before any subsequent mutation.
Answers during waiting or ongoing handling continue the same assignment row; use `TicketAssignmentAnswer` with that `assignment_id`, observed `expected_last_event_id`, stable `operation_key`, and answer `body`, not an unfenced comment as a substitute. Do not reassign merely to deliver an answer or automatically declare inprogress on arrival. After done, an explicit reissue creates a new row/assignment_id even for the same principal and preserves the prior result. Reopen alone is neither reissue nor resurrection of an old Worker. Release/replacement remains separate from the old row's last status/result; done does not require unassignment. Worker replacement alone does not create a new delegation. Dependencies are diagnostics for judging partial work or waiting, not a blanket start gate; never start dependencies implicitly.
For a new self-report, read the exact current assignment and send `assignment_id`, observed `last_event_id` as `expected_last_event_id`, a stable `operation_key`, and the complete tracking payload. Do not compare status alone or use a replay receipt as a fresh observation. Exact retries retain the same key and full input; reusing a key with changed input is rejected. After stale/replaced/cleared assignment or CAS conflict, reread and reconcile rather than silently overwrite, switch to a newer assignment, or retry as a new operation. Unknown outcomes are not success or failure: reconcile durable evidence and retry only the same operation when appropriate. Assign persistence, notification delivery, Runtime acceptance and actual work start are distinct outcomes; a delivery problem after saved Assign must not prompt another Assign. If a required typed operation or diagnostic endpoint is unavailable, report the concrete API insufficiency instead of inventing state, using a CLI/storage fallback, or creating a second ledger.
Ticket requests, continuing input, changes and MR/implementation results follow the current assigned Subject; delivery resolves that Subject's current body rather than relying on a previous Worker. A notice, retained receipt, historical assignment or saved result is evidence to reread, not authority for an old body or released/replaced assignee to start or continue new work. After reassignment, release, body replacement or permission revocation, recheck current assignment and effective authority; do not restore execution rights from history. A background Worker's result is a bounded factual report, not the assigned Subject's outward decision, adoption judgment or handling update.
For a new self-report, read the exact current assignment and verify that the Host-bound Subject and current body are authorized for that assignment; send `assignment_id`, observed `last_event_id` as `expected_last_event_id`, a stable `operation_key`, and the complete tracking payload. Do not compare status alone or use a replay receipt as a fresh observation. Exact retries retain the same key and full input; reusing a key with changed input is rejected. After stale/replaced/cleared assignment or CAS conflict, reread and reconcile rather than silently overwrite, switch to a newer assignment, or retry as a new operation. Unknown outcomes are not success or failure: reconcile durable evidence and retry only the same operation when appropriate. Assign persistence, notification delivery, Runtime acceptance and actual work start are distinct outcomes; a delivery problem after saved Assign must not prompt another Assign. If a required typed operation or diagnostic endpoint is unavailable, report the concrete API insufficiency instead of inventing state, using a CLI/storage fallback, or creating a second ledger.
Treat case state, assignment handling, and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker.
@@ -14,4 +14,4 @@ Assigned Tickets have unreceived input:
{% if omitted_ticket_count > 0 -%}
Additional assigned Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
{% endif -%}
Reread the current Ticket state before acting, including current assignment tracking and new input. Assign is delegation; no ready/Queue step is required. Handling status is your self-report, not delivery or Worker state. Waiting alone and done history are not new work. Dependencies are diagnostics for partial work, not a blanket start gate; never implicitly start dependencies. Preserve current assignment identity, authenticated Worker, unfinished work, and Workdir/resource authority; do not create duplicate work.
Reread the current Ticket state before acting, including the assigned Subject, current assignment/event identity and new input. Verify that the Host-bound Subject and current body still match the assignment and have the required effective grants; the notice does not establish ownership or authority. Assign is delegation to the selected Subject; no ready/Queue step is required. No fixed Workspace-wide assignee or Orchestrator singleton is required. Handling status is the assigned Subject's self-report, not delivery or Worker state. Waiting alone and done history are not new work. Old-body receipts and released/replaced assignments do not authorize new work, restore or duplicate execution. Dependencies are diagnostics for partial work, not a blanket start gate; never implicitly start dependencies. Preserve Subject/body ownership, unfinished work, Ticket target constraints and Workdir/resource authority.
@@ -5,4 +5,4 @@ Assigned Tickets have input requiring attention:
{% if omitted_ticket_count > 0 -%}
Additional assigned Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
{% endif -%}
Reread the current Ticket, current assignment/event identity, handling and new input before acting. Assign is delegation; no ready/Queue step is required. Waiting alone or done history is not new work. Answers during waiting continue the same assignment; explicit reissue after done creates a new row. Supply the observed orchestrator_assignment_id for starts. Handling is a self-report, not delivery, acceptance or Worker state. Preserve authenticated Worker, unfinished work and Workdir/resource authority; dependencies are diagnostics, never implicitly started. Do not create duplicate work or infer delivery success from this notice.
Reread the current Ticket, assigned Subject, current assignment/event identity, handling and new input before acting. Verify current Host-bound Subject/body ownership and effective grants; this notice is not authority. Assign is delegation to the selected Subject; no ready/Queue step is required. No fixed Workspace-wide assignee or Orchestrator singleton is required. Waiting alone or done history is not new work. Answers during waiting continue the same assignment; explicit reissue after done creates a new row. For Ticket starts, supply the observed current Subject assignment ID in the compatibility field `orchestrator_assignment_id`; that name grants no privileged role. Handling is the assigned Subject's self-report, not delivery, acceptance or Worker state. Preserve Subject/body ownership, unfinished work, Ticket target constraints and Workdir/resource authority; dependencies are diagnostics, never implicitly started. Do not start new work from an old body's receipt or a released/replaced assignment, create duplicate work, or infer delivery success from this notice.
+2 -2
View File
@@ -1,5 +1,5 @@
You are the assigned Coder. Implement and validate the requested scope in the bound Workdir. The Orchestrator selects, adapts, or rejects your changes; keep dependencies and validation clear for that decision.
You are the Coder delegated bounded implementation work. Implement and validate the requested scope in the bound Workdir. The assigned Subject selects, adapts, or rejects your changes; keep dependencies and validation clear for that decision. Your implementation Worker identity is not the Ticket's durable Subject assignee, and this Profile does not confer Subject body ownership or assignment-handling authority.
Use `TicketImplementationResult` for handoffs and blockers, not `TicketAssignmentAnswer` or a comment as a notification substitute. `SpawnTicketWorker` defaults this Profile to `builtin:coder-review` when Flow is omitted; explicit Flow selection or disabling is respected. When that Flow is active, own implementation, validation, publication, the direct Reviewer SubWorker, fixes, and confirmation of the structured MR verdict before the reviewed handoff. Do not leave the review cycle for the Orchestrator to start. Blocker and partial-result reports do not require MR approval; do not describe them as a completed reviewed handoff. Without that Flow, follow the requested review and return scope rather than imposing a universal MR approval gate.
Use `TicketImplementationResult` for handoffs and blockers, not `TicketAssignmentAnswer` or a comment as a notification substitute. `SpawnTicketWorker` defaults this Profile to `builtin:coder-review` when Flow is omitted; explicit Flow selection or disabling is respected. When that Flow is active, own implementation, validation, publication, the direct Reviewer SubWorker, fixes, and confirmation of the structured MR verdict before the reviewed handoff. Do not leave the review cycle for the assigned Subject to start. Blocker and partial-result reports do not require MR approval; do not describe them as a completed reviewed handoff. Without that Flow, follow the requested review and return scope rather than imposing a universal MR approval gate.
Publish the committed work branch, not the target branch or unrelated refs. Use Workdir aliases when multiple attachments exist; writable Ticket targets are available repositories, not mandatory deliverables.
+1 -1
View File
@@ -8,6 +8,6 @@ Use one Ticket for a deliverable that can be explained as a specification or fea
Create or update Tickets only after user agreement or an explicit user instruction to record the agreed draft. Durable Ticket item/thread/resolution text should follow the configured worker language unless a Ticket-specific record language instruction is supplied by the host/environment.
Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to explicit user/Orchestrator delegation. Ticket creation records an open case, not execution readiness. Classification tags use the shared stable tag identities and do not replace closure reasons or approval conditions. Assign starts Orchestrator tracking without a separate ready/Queue step; this role's authoring handoff is not itself Assign, Worker start, handling done, or Ticket closure.
Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to explicit user delegation or authorized handling by the assigned Subject. Ticket creation records an open case, not execution readiness. Classification tags use the shared stable tag identities and do not replace closure reasons or approval conditions. Assign starts the selected Subject's tracking without a separate ready/Queue step; no fixed Workspace-wide target or Orchestrator singleton is required. This role's authoring handoff is not itself Assign, Worker start, handling done, or Ticket closure.
Do not infer requirements from a Ticket id or title alone; read the relevant Ticket record before updating it.
+3 -3
View File
@@ -1,13 +1,13 @@
You are the Ticket Orchestrator. Select, adapt, or reject Coder changes and validate the combined result. Use Tickets and Merge Requests by default, adapting the workflow when the request calls for a different approach.
You coordinate Ticket outcomes for the assigned Subject: read the request, plan, delegate, wait, evaluate results, and judge when the assigned handling is done. Select, adapt, or reject Coder changes and validate the combined result. Use Tickets and Merge Requests by default, adapting the workflow when the request calls for a different approach. The `orchestrator` role/Profile name is retained for compatibility; it neither binds you to a Subject nor grants privileged control. Before acting for an assigned Subject, confirm the current assignment, Host-bound Subject/body ownership and effective operation/resource grants. Do not assume a fixed Workspace-wide assignee or require a Workspace Orchestrator singleton. Body replacement preserves the Subject's assignment; different Subjects may handle separate Tickets on separate branches and Workdirs in parallel without relaxing resource-conflict protection.
{% include "common.git" %}
After integrating changes, push the target branch through its configured normal non-force path and verify that the repository provider resolves it to the exact resulting commit before reporting integration complete or calling `CompleteMergeRequest`. Honor explicit local-only or no-push instructions; otherwise, a local merge alone is not completion. If publication or verification fails, report the blocker rather than claim completion.
Delegate bounded Ticket work with `SpawnTicketWorker` using the current `orchestrator_assignment_id`; choose Profile, Flow, and Workdir attachments for the task. For code implementation, select `builtin:coder`: omitting `flow` applies `builtin:coder-review`. Use explicit `flow: null` when the request calls for no Flow, or an explicit selector for a different Flow. Other Profiles receive no default Flow.
Delegate bounded Ticket work with `SpawnTicketWorker` using the current `orchestrator_assignment_id` observed from the Subject assignment; this is a compatibility field name, not an Orchestrator identity. Choose Profile, Flow, and Workdir attachments for the task. This Ticket-specific path must preserve current-assignee and Repository/ref/Workdir checks as well as effective operation/resource grants; a role name or assignment ID alone is not permission. For code implementation, select `builtin:coder`: omitting `flow` applies `builtin:coder-review`. Use explicit `flow: null` when the request calls for no Flow, or an explicit selector for a different Flow. Other Profiles receive no default Flow.
In the coder-review cycle, the Coder owns publication, its direct Reviewer SubWorker, fixes, and the structured MR verdict before the reviewed handoff. If that cycle is incomplete, first have the Coder continue it; do not routinely start a second review of the same candidate yourself. Additional independent review remains appropriate when needed. A recorded review request is not evidence of an active Reviewer; confirm the current MR result and responsible Worker's progress before reporting review as running. You own candidate selection, combined validation, and integration.
For `implementation-result:<result ID>` notifications, read `TicketImplementationResultQuery`; for `mr-event:<MR ID>:<event ID>`, reread the MR.
For `implementation-result:<result ID>` notifications, read `TicketImplementationResultQuery`; for `mr-event:<MR ID>:<event ID>`, reread the MR. Also reread the Ticket and current Subject assignment before deciding on a result. A background Worker report, notification acceptance, or previous body's receipt does not establish current authority, processing success, handling done, MR approval/integration or Ticket closure. Evaluate the evidence against current intent rather than forwarding it as the Subject's decision.
For Ticket-dedicated cleanup: stop the Worker, remove it, then delete the Workdir only after checking occupancy and retained changes. `WorkerRemove` releases attachments but preserves the Workdir. Retained terminal responsibility does not require unassignment; idle or stopped status does not end unfinished work.
+1 -1
View File
@@ -1,6 +1,6 @@
You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required.
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers with `TicketImplementationResult` using only the Ticket, kind, bounded body and optional MR evidence. Backend verifies an authenticated active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. MR/source references are optional and must identify retained MR evidence; research and documents use the same operation without a repository. The Host owns transport retry identity: retries of the same tool invocation and full input are idempotent, whereas a new invocation is a new report. After an uncertain outcome, reconcile saved evidence rather than blindly submitting a new invocation; do not manage report retry keys in model input. A saved result and its notification do not report handling done or conclude the Ticket. Being the assigned Worker does not authorize `TicketAssignmentAnswer` or self-reporting as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, the assigned Subject's self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers with `TicketImplementationResult` using only the Ticket, kind, bounded body and optional MR evidence. Backend verifies an authenticated active Workspace Worker; no current or pinned implementation assignment or Subject assignment is required to report facts. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. MR/source references are optional and must identify retained MR evidence; research and documents use the same operation without a repository. The Host owns transport retry identity: retries of the same tool invocation and full input are idempotent, whereas a new invocation is a new report. After an uncertain outcome, reconcile saved evidence rather than blindly submitting a new invocation; do not manage report retry keys in model input. A saved result and its notification do not report handling done or conclude the Ticket. Being the implementation Worker does not by itself authorize `TicketAssignmentAnswer`, Subject body ownership or assignment self-reporting. The durable assignee is the Subject, not this Worker or its Profile. Only the authorized current body of the currently assigned Subject may self-report that Subject's handling; a background result does not make that decision. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketCaseUpdate` only for a requested case judgment: reasoned close, reopen, or correction of the current closure. Choose `completed` only when actually fulfilled; `not_planned`, `duplicate`, `unknown`, or a documented custom reason do not claim fulfillment. The immutable history preserves prior actor, reason, explanation, and references. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected case state plus nullable `closure_operation_key` from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction.
+1
View File
@@ -13,6 +13,7 @@
"test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts",
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
"test:browser:subject-handling": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/subject-handling.browser.test.ts",
"test:ticket:real-api": "cd ../.. && deno task test:ticket:real-api",
"test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored",
"build": "deno run -A npm:vite@8.3.2 build",
@@ -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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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
@@ -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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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
@@ -107,9 +107,9 @@ export type TicketAssignmentInputDeliveryState = "not_reserved" | "reserved" | "
export type TicketAssignmentInputDiagnostic = { acceptance_outcome_unknown: boolean; delivery_id?: string | null; input_event_id: string; notification: TicketAssignmentInputDeliveryState; };
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentPrincipal = { kind: "subject"; subject_id: string; } | { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentPrincipalSnapshot = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentPrincipalSnapshot = { kind: "subject"; subject_id: string; } | { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentRelease = { action: TicketAssignmentReleaseAction; actor: string; at: string; event_id: string; last_tracking?: TicketAssignmentTracking | null; operation_key?: string | null; reason?: string | null; replacement_assignment_id?: string | null; };
@@ -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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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
@@ -8,6 +8,7 @@ import { fixturePost } from "../home/dashboard.test-fixtures.ts";
import {
TICKET_BROWSER_API_LOAD_POLICY,
parseTicketEvent,
parseAssignmentTrackingProjection,
parseMergeRequestDetailResponse,
parseMergeRequestListResponse,
parseObjectiveListResponse,
@@ -862,3 +863,45 @@ Deno.test("case decoders reject legacy current states and malformed closure judg
"closure_operation_key must be a string",
);
});
Deno.test("Subject assignment projection preserves identity and legacy audit without singleton normalization", () => {
const base = {
workspace_id: "w",
ticket_id: "t",
assignment_id: "a",
role: "orchestrator",
assigned_by: "Mika",
assigned_at: "2026-01-01T00:00:00Z",
current: true,
tracking: { status: "waiting" },
last_event_id: "e",
};
for (
const principal of [
{ kind: "subject", subject_id: "subject-a" },
{ kind: "subject", subject_id: "subject-b" },
{ kind: "user", account_id: "alice" },
{ kind: "worker", runtime_id: "r", worker_id: "w" },
{ kind: "workspace_agent", agent_key: "workspace-orchestrator" },
]
) {
assertEquals(
parseAssignmentTrackingProjection({ ...base, principal }, "assignment")
.principal,
principal,
);
}
for (
const principal of [
{ kind: "subject" },
{ kind: "subject", subject_id: 3 },
{ kind: "subject", subject_id: "a", worker_id: "w" },
]
) {
assertThrows(
() =>
parseAssignmentTrackingProjection({ ...base, principal }, "assignment"),
"assignment.principal",
);
}
});
@@ -578,6 +578,10 @@ function parseTicketAssignmentPrincipal(
): TicketAssignmentPrincipal {
const item = object(value, label);
const kind = string(item.kind, `${label}.kind`);
if (kind === "subject") {
exact(item, ["subject_id", "kind"], label);
return { kind, subject_id: string(item.subject_id, `${label}.subject_id`) };
}
if (kind === "user") {
exact(item, ["account_id", "kind"], label);
return { kind, account_id: string(item.account_id, `${label}.account_id`) };
@@ -53,7 +53,7 @@ const current: TicketAssignmentTrackingProjection = {
assignment_id: "assignment-1",
role: "orchestrator",
current: true,
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
principal: { kind: "subject", subject_id: "subject-a" },
assigned_by: "account/user",
assigned_at: "2026-01-01T00:00:00Z",
tracking: {
@@ -82,12 +82,14 @@ Deno.test("assignment retry retains the exact observed pointer, operation key an
"ticket/one",
old,
"operation-1",
false,
{ kind: "subject", subject_id: "subject-a" },
);
old.assignments[0].assignment_id = "replacement";
assertEquals(JSON.parse(operation.body!), {
operation_id: "operation-1",
expected_assignment_id: "assignment-1",
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
principal: { kind: "subject", subject_id: "subject-a" },
});
assertEquals(
operation.path,
@@ -99,6 +101,8 @@ Deno.test("assignment retry retains the exact observed pointer, operation key an
"ticket/one",
old,
"operation-2",
false,
{ kind: "subject", subject_id: "subject-b" },
);
assertEquals(JSON.parse(fresh.body!).expected_assignment_id, "replacement");
const clear = assignmentOperation(
@@ -273,3 +277,38 @@ Deno.test("conflict, permission, unavailable and unknown errors never claim succ
"Outcome unknown",
);
});
Deno.test("new Assign requires an explicit Subject; legacy assignments remain releasable audit", () => {
assertThrows(() =>
assignmentOperation("team", "ticket", observed(), "missing")
);
assertThrows(() =>
assignmentOperation("team", "ticket", observed(), "empty", false, {
kind: "subject",
subject_id: " ",
})
);
for (
const principal of [{ kind: "user", account_id: "alice" }, {
kind: "worker",
runtime_id: "r",
worker_id: "w",
}, {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
}] as const
) {
assertThrows(() =>
answerOperation("team", "ticket", { ...current, principal }, "old", "op")
);
const operation = assignmentOperation(
"team",
"ticket",
{ assignments: [{ ...observed().assignments[0], principal }] },
"release",
true,
);
assertEquals(operation.method, "DELETE");
assertStringIncludes(operation.path, "assignment_id=assignment-1");
}
});
@@ -104,10 +104,10 @@ export function assignmentOperation(
observed: Pick<TicketDetail, "assignments">,
operationId: string,
clear = false,
principal: SetTicketRoleAssignmentRequest["principal"] = {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
},
principal?: Extract<
SetTicketRoleAssignmentRequest["principal"],
{ kind: "subject" }
>,
): ObservedOperation {
const current = observed.assignments.find((row) =>
row.role === "orchestrator"
@@ -126,6 +126,11 @@ export function assignmentOperation(
"DELETE",
);
}
if (
!principal || principal.kind !== "subject" || !principal.subject_id.trim()
) {
throw new Error("Select a Subject for the new assignment");
}
const request: SetTicketRoleAssignmentRequest = {
operation_id: operationId,
expected_assignment_id: current?.assignment_id ?? null,
@@ -212,7 +217,8 @@ export function answerOperation(
operationKey: string,
): ObservedOperation {
if (
!observed.current || !observed.last_event_id || !observed.tracking ||
observed.principal.kind !== "subject" || !observed.current ||
!observed.last_event_id || !observed.tracking ||
observed.tracking.status === "done"
) throw new Error("Read actionable current assignment before answering");
const request: TicketAssignmentAnswer = {
@@ -13,7 +13,7 @@ function assigned(status: "waiting" | "done" = "waiting"): TicketDetail {
const row: TicketAssignmentTrackingProjection = {
assignment_id: "assignment-1", role: "orchestrator", ticket_id: "internal-T-1", workspace_id: "team",
assigned_at: "2026-10-13T09:00:00Z", assigned_by: "Mika", current: true,
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, last_event_id: "handling-1",
principal: { kind: "subject", subject_id: "subject-a" }, last_event_id: "handling-1",
tracking: { status, waiting_reason: "Choose the policy", progress: "Compared options", next_action: "Confirm the choice", result: "Comparison delivered" },
};
return { ...fixtureDetail("T-1"), assignment_tracking: [row],
@@ -24,6 +24,7 @@ function assigned(status: "waiting" | "done" = "waiting"): TicketDetail {
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response> = () => { throw new Error("Unexpected request"); }, emptyAssignments = true) {
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path.includes("/subjektiv/subjects")) return Response.json(subjectPage);
if (emptyAssignments && path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null });
return await handle(path, init);
});
@@ -31,7 +32,7 @@ function installFetch(handle: (path: string, init?: RequestInit) => Response | P
return fetcher;
}
function mount(ticket = assigned(), refresh = vi.fn(async () => {})) {
return { ...render(TicketHandling, { workspaceId: "team", ticket, orchestrator: null, refresh }), refresh };
return { ...render(TicketHandling, { workspaceId: "team", ticket, refresh }), refresh };
}
function expectNoDiagnostics(fetcher: ReturnType<typeof installFetch>, view: ReturnType<typeof mount>) {
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
@@ -43,10 +44,10 @@ function expectNoDiagnostics(fetcher: ReturnType<typeof installFetch>, view: Ret
test("mount retains assignment and self-reported handling, progress and result without delivery UI or diagnostics reads", async () => {
const fetcher = installFetch();
const view = mount();
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
expect(view.getByText("Self-reported handling: waiting")).toBeTruthy();
for (const text of ["Choose the policy", "Compared options", "Confirm the choice", "Comparison delivered", "Assignment details"]) expect(view.getByText(text)).toBeTruthy();
expect(view.container.querySelector(".ticket-orchestrator-worker")).not.toBeNull();
expect(view.container.querySelector(".ticket-subject-worker")).not.toBeNull();
expectNoDiagnostics(fetcher, view);
});
@@ -56,13 +57,14 @@ test("assignment save refreshes assignment history without diagnostics or delive
expect(init?.method).toBe("PUT");
return Response.json({ ticket_id: "internal-T-1", workspace_id: "team", assignment: {
assignment_id: "assignment-2", assigned_at: "2026-10-13T10:00:00Z", assigned_by: "Mika", role: "orchestrator",
ticket_id: "internal-T-1", workspace_id: "team", principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
ticket_id: "internal-T-1", workspace_id: "team", principal: { kind: "subject", subject_id: "subject-a" },
} });
});
const view = mount(assigned("done"));
await chooseSubject(view);
await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(fetcher).toHaveBeenCalledTimes(4);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
@@ -81,7 +83,7 @@ test("answer save keeps handling unchanged and refreshes history without diagnos
await fireEvent.input(view.getByRole("textbox", { name: "Answer / continue this handling" }), { target: { value: "Use read-only comparison" } });
await fireEvent.click(view.getByRole("button", { name: "Send answer to this assignment" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(fetcher).toHaveBeenCalledTimes(4);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
@@ -93,11 +95,12 @@ test("answer save keeps handling unchanged and refreshes history without diagnos
test("uncertain assignment reconciliation only refreshes the projection and assignment history", async () => {
const fetcher = installFetch(() => { throw new Error("Response lost"); });
const view = mount(assigned("done"));
await chooseSubject(view);
await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Outcome unknown"));
await fireEvent.click(view.getByRole("button", { name: "Refresh and reconcile assignment" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(fetcher).toHaveBeenCalledTimes(4);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
@@ -186,10 +189,10 @@ test("released done is retained as past result without current Answer or implici
await fireEvent.click(view.getByRole("button", { name: /Past · done/ }));
await waitFor(() => expect(view.getByText(/unassigned by Mika/)).toBeTruthy());
expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy();
expect(view.getByText("No current Orchestrator assignment.")).toBeTruthy();
expect(view.getByText("No current Subject assignment.")).toBeTruthy();
expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull();
expect(view.queryByRole("button", { name: "Read replacement assignment" })).toBeNull();
expect(view.getByRole("button", { name: "Request Orchestrator" })).toBeTruthy();
expect(view.getByRole("button", { name: "Assign Subject" })).toBeTruthy();
expect(fetcher.mock.calls.every(([, init]) => !init?.method || init.method === "GET")).toBe(true);
expectNoDiagnostics(fetcher, view);
});
@@ -198,15 +201,15 @@ test("case close and reopen keep done result non-actionable and require an expli
const fetcher = installFetch();
const ticket = assigned("done");
const view = mount(ticket);
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
for (const state of ["closed", "open"] as const) {
await view.rerender({ workspaceId: "team", ticket: { ...ticket, state }, orchestrator: null, refresh: view.refresh });
await view.rerender({ workspaceId: "team", ticket: { ...ticket, state }, refresh: view.refresh });
expect(view.getByText(`This handling was returned. Ticket ${state} is unchanged.`)).toBeTruthy();
expect(view.getByText("Comparison delivered")).toBeTruthy();
expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull();
expect(view.getByRole("button", { name: "Request again (new assignment)" })).toBeTruthy();
}
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(view.refresh).not.toHaveBeenCalled();
expectNoDiagnostics(fetcher, view);
});
@@ -241,3 +244,24 @@ test("releasing responsibility while reading an old result uses current assignme
expect(fetcher.mock.calls.filter(([, init]) => init?.method === "DELETE")).toHaveLength(1);
expectNoDiagnostics(fetcher, view);
});
const subjectPage = { limit: 100, items: [{ id: "subject-a", role: "Research", state: "active", behavior_md: "", memory_fingerprint: "f", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", current_worker: null }], has_more: false, next_cursor: null };
async function chooseSubject(view: ReturnType<typeof mount>) {
await fireEvent.click(view.getByText("Choose responsible Subject"));
await waitFor(() => expect(view.queryByText("Loading Subjects…")).toBeNull());
// Current Subject is the observed reissue candidate; different-Subject native selection is covered in Playwright.
}
test("legacy principal is audit-only with no Answer or mandatory singleton observation", async () => {
const ticket = assigned();
ticket.assignment_tracking[0].principal = { kind: "workspace_agent", agent_key: "workspace-orchestrator" };
ticket.assignments[0].principal = ticket.assignment_tracking[0].principal;
const fetcher = installFetch();
const view = mount(ticket);
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
expect(view.getByText(/Legacy assignment retained for audit only/)).toBeTruthy();
expect(view.getByText("Workspace agent: workspace-orchestrator")).toBeTruthy();
expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull();
expect(fetcher.mock.calls.some(([path]) => String(path).endsWith("/orchestrator"))).toBe(false);
expect(view.queryByLabelText("Principal kind")).toBeNull();
});
@@ -2,11 +2,14 @@
import { onDestroy, onMount } from "svelte";
import BevelLine from "#lib/workspace/ui/BevelLine.svelte";
import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts";
import type { WorkspaceOrchestratorStatus } from "./ticket-panel.ts";
import { assignmentPrincipalLabel } from "./ticket-panel.ts";
import type { SubjektivSubjectResponse } from "#lib/generated/memory-api.ts";
import { parseSubjektivSubjectListResponse, MEMORY_API_LOAD_POLICY } from "#lib/workspace/memory/api.ts";
import { workspaceApiPath } from "#lib/workspace/api/http.ts";
import { workspaceApiJson, workspaceApiJsonWithBody, formatDate } from "#lib/workspace/api/http.ts";
import { parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts";
import { assignmentOperation, answerOperation, parseAnswerReceipt, parseAssignmentsPage, operationError, parseTracking, parseTrackingHistory, trackingPath, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
let { workspaceId, ticket, orchestrator, refresh }: { workspaceId: string; ticket: TicketDetail; orchestrator: WorkspaceOrchestratorStatus | null; refresh: () => Promise<void> } = $props();
let { workspaceId, ticket, refresh }: { workspaceId: string; ticket: TicketDetail; refresh: () => Promise<void> } = $props();
const current = $derived(ticket.assignment_tracking.find((row) => row.current && row.role === "orchestrator"));
let busy = $state(false);
let pending = $state<ObservedOperation | null>(null);
@@ -16,15 +19,30 @@
let assignments = $state<TicketAssignmentsPage | null>(null);
let assignmentPageBusy = $state(false);
let answer = $state("");
let principalKind = $state<"workspace_agent" | "user" | "worker">("workspace_agent");
let accountId = $state("");
let runtimeId = $state("");
let workerId = $state("");
const principalReady = $derived(principalKind === "workspace_agent" || (principalKind === "user" ? !!accountId.trim() : !!runtimeId.trim() && !!workerId.trim()));
function selectedPrincipal(): TicketAssignmentPrincipalSnapshot {
if (principalKind === "user") return { kind: "user", account_id: accountId.trim() };
if (principalKind === "worker") return { kind: "worker", runtime_id: runtimeId.trim(), worker_id: workerId.trim() };
return { kind: "workspace_agent", agent_key: "workspace-orchestrator" };
let subjectId = $state("");
let subjects = $state<SubjektivSubjectResponse[]>([]);
let subjectsBusy = $state(false);
let subjectsError = $state<string | null>(null);
let subjectsCursor = $state<string | null>(null);
const principalReady = $derived(subjects.some((subject) => subject.id === subjectId && subject.state === "active"));
function selectedPrincipal(): Extract<TicketAssignmentPrincipalSnapshot, { kind: "subject" }> | undefined {
return principalReady ? { kind: "subject", subject_id: subjectId } : undefined;
}
async function readSubjects(more = false) {
if (subjectsBusy) return;
subjectsBusy = true; subjectsError = null;
const query = new URLSearchParams({ limit: "100" });
if (more && subjectsCursor) query.set("cursor", subjectsCursor);
try {
const page = await workspaceApiJson(workspaceApiPath(workspaceId, "/subjektiv/subjects") + "?" + query, parseSubjektivSubjectListResponse, MEMORY_API_LOAD_POLICY.maxResponseBytes);
if (!alive) return;
subjects = more ? [...new Map([...subjects, ...page.items].map((subject) => [subject.id, subject])).values()] : page.items;
subjectsCursor = page.next_cursor ?? null;
// 再依頼の初期候補は観測済みの担当だけ。任意の先頭Subjectへは割り当てない。
const observedPrincipal = current?.principal;
if (!subjectId && observedPrincipal?.kind === "subject" && subjects.some((subject) => subject.id === observedPrincipal.subject_id && subject.state === "active")) subjectId = observedPrincipal.subject_id;
} catch (cause) { if (alive) subjectsError = operationError(cause); }
finally { subjectsBusy = false; }
}
let historyId = $state("");
let retained = $state<TicketAssignmentTrackingProjection | null>(null);
@@ -97,11 +115,12 @@
if (!current || pending || !answer.trim()) return;
void run(answerOperation(workspaceId, ticket.id, current, answer, crypto.randomUUID()), "answer");
}
onMount(() => { void readAssignments(); });
onMount(() => { void readAssignments(); if (ticket.action_eligibility.can_assign_orchestrator) void readSubjects(); });
</script>
<section class="ticket-detail-section ticket-handling">
<div class="ticket-section-heading"><h2>Orchestrator handling</h2><a href={`/w/${encodeURIComponent(workspaceId)}/orchestrator`}>All handling</a></div>
<div class="ticket-section-heading"><h2>Subject handling</h2><a href={`/w/${encodeURIComponent(workspaceId)}/orchestrator`}>All handling</a></div>
{#if current}
<p>{assignmentPrincipalLabel(current.principal)}</p>
<p><strong>Self-reported handling: {current.tracking?.status ?? "unknown"}</strong></p>
{#if current.tracking}
<dl>
@@ -115,10 +134,9 @@
{#if current.migration_diagnostic}<p class="workspace-callout">{current.migration_diagnostic}</p>{/if}
{#if current.tracking?.status === "done"}<p>This handling was returned. Ticket {ticket.state} is unchanged.</p>{/if}
<details class="ticket-assignment-details"><summary>Assignment details</summary>
<p>Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}</p>
<p>Assignment <code>{current.assignment_id}</code> · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}</p>
</details>
{:else}<p class="workspace-empty-copy">No current Orchestrator assignment.</p>{/if}
{:else}<p class="workspace-empty-copy">No current Subject assignment.</p>{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
{#if savedNotice}<p role="status">{savedNotice}</p>{/if}
{#if pending}
@@ -128,26 +146,31 @@
{/if}
{#if ticket.state === "closed" && ticket.action_eligibility.can_assign_orchestrator}<p class="workspace-empty-copy">A delegation can be saved, but this closed case does not admit new work. Reopening the case is a separate decision and does not itself reissue handling.</p>{/if}
{#if ticket.action_eligibility.can_assign_orchestrator}
<details class="assignment-principal"><summary>Choose responsibility principal</summary>
<details class="assignment-principal"><summary>Choose responsible Subject</summary>
<fieldset class="ticket-control-form" disabled={busy || pending !== null}>
<label>Principal kind<select bind:value={principalKind}><option value="workspace_agent">Workspace Orchestrator agent</option><option value="user">User account</option><option value="worker">Worker principal</option></select></label>
{#if principalKind === "user"}<label>Account identity<input bind:value={accountId} maxlength="1024" /></label>{/if}
{#if principalKind === "worker"}<label>Runtime identity<input bind:value={runtimeId} maxlength="1024" /></label><label>Worker identity<input bind:value={workerId} maxlength="1024" /></label>{/if}
<label>Subject<select bind:value={subjectId} disabled={subjectsBusy}>
<option value="">Select a Subject…</option>
{#each subjects as subject (subject.id)}<option value={subject.id} disabled={subject.state !== "active"}>{subject.role} · {subject.id}{subject.state !== "active" ? " (retired)" : ""}</option>{/each}
</select></label>
</fieldset>
<p>Backend validates the selected principal. Responsibility does not choose or start a concrete Worker.</p>
{#if subjectsBusy}<p role="status">Loading Subjects…</p>{/if}
{#if subjectsError}<p class="workspace-callout is-error" role="alert">Subjects: {subjectsError}</p><button class="workspace-secondary-button" disabled={subjectsBusy} onclick={() => void readSubjects()}>Retry Subjects</button>{/if}
{#if subjectsCursor}<button class="workspace-secondary-button" disabled={subjectsBusy} onclick={() => void readSubjects(true)}>More Subjects</button>{/if}
{#if !subjectsBusy && !subjectsError && !subjects.length}<p>No Subjects available. Create or configure a Subject in <a href={`/w/${encodeURIComponent(workspaceId)}/memory`}>Memory</a>.</p>{/if}
<p>Backend validates the Subject and current permissions. Assignment does not grant permissions or select or start a concrete Worker.</p>
</details>
{/if}
<div class="handling-actions">
{#if ticket.action_eligibility.can_assign_orchestrator}
{#if !current || current.tracking?.status === "done"}
<button class="workspace-primary-button" disabled={busy || pending !== null || !principalReady} onclick={() => delegate()}>{busy ? "Saving…" : current ? "Request again (new assignment)" : "Request Orchestrator"}</button>
<button class="workspace-primary-button" disabled={busy || pending !== null || !principalReady} onclick={() => delegate()}>{busy ? "Saving…" : current ? "Request again (new assignment)" : "Assign Subject"}</button>
{:else}
<details><summary>Replace this delegation</summary><p>A replacement starts a new assignment even for the same principal. The previous result and release history remain.</p><button class="workspace-secondary-button" disabled={busy || pending !== null || !principalReady} onclick={() => { if (confirm("Replace this delegation with a new assignment to the selected principal?")) delegate(); }}>Replace assignment</button></details>
{/if}
{/if}
{#if ticket.action_eligibility.can_unassign_orchestrator}<button class="workspace-danger-button" disabled={busy || pending !== null} onclick={() => { if (confirm("Release responsibility? This does not stop any running Worker.")) delegate(true); }}>Release assignment</button>{/if}
</div>
{#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done"}
{#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done" && current.principal.kind === "subject"}
<form class="ticket-control-form ticket-answer" onsubmit={submitAnswer}>
<label>Answer / continue this handling<textarea bind:value={answer} rows="3" required disabled={busy || pending !== null}></textarea></label>
<button class="workspace-secondary-button" disabled={busy || pending !== null || !answer.trim()}>Send answer to this assignment</button>
@@ -169,10 +192,15 @@
</details>
</section>
<BevelLine decorative />
<details class="ticket-detail-section ticket-orchestrator-worker">
<summary>Orchestrator Worker</summary>
{#if orchestrator?.worker}<p>{orchestrator.worker.runtime_id}/{orchestrator.worker.worker_id} · actual state: <strong>{orchestrator.worker.state}</strong> · {orchestrator.online ? "online" : "offline"}</p><a href={`/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(orchestrator.worker.worker_id)}?runtimeId=${encodeURIComponent(orchestrator.worker.runtime_id)}`}>Open Worker</a>{:else}<p>Worker observation unavailable. This does not change handling status.</p>{/if}
</details>
{#if current?.principal.kind === "subject"}
<details class="ticket-detail-section ticket-subject-worker">
<summary>Subject and current Worker</summary>
<p>Assignment follows the Subject across Worker replacement. Worker availability does not change handling status.</p>
<a href={`/w/${encodeURIComponent(workspaceId)}/memory/${encodeURIComponent(current.principal.subject_id)}`}>Open responsible Subject and current Worker</a>
</details>
{:else if current}
<p class="workspace-callout">Legacy assignment retained for audit only. Select a Subject to create a new assignment; old receipts do not authorize new work.</p>
{/if}
<style>
.assignment-principal fieldset { min-width: 0; margin: var(--space-3, 0.75rem) 0 0; padding: 0; border: 0; }
dl { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
@@ -21,7 +21,7 @@ const assignment = (
ticket_id: "T-1",
assignment_id: id,
role: "orchestrator",
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
principal: { kind: "subject", subject_id: "subject-a" },
assigned_by: "user",
assigned_at: "2026-01-01T00:00:00Z",
current: true,
@@ -51,6 +51,7 @@ function detail(row = assignment()): TicketDetail {
};
}
function reads(path: string, row: TicketAssignmentTrackingProjection) {
if (path.includes("/subjektiv/subjects")) return Response.json({ limit: 100, items: [{ id: "subject-a", role: "Research", state: "active", behavior_md: "", memory_fingerprint: "f", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }], has_more: false, next_cursor: null });
if (path.includes("/assignment-tracking/assignments")) {
return Response.json({ items: [row], next_assignment_id: null });
}
@@ -80,7 +81,6 @@ test("an answer response loss keeps exact assignment/event/key/body for retry ev
const view = render(TicketHandling, {
workspaceId: "team",
ticket: detail(row),
orchestrator: null,
refresh,
});
await fireEvent.input(
@@ -136,11 +136,13 @@ test("a done reissue persists before a failed refresh and does not pretend deliv
const view = render(TicketHandling, {
workspaceId: "team",
ticket: detail(row),
orchestrator: null,
refresh: vi.fn(async () => {
throw new Error("refresh unavailable");
}),
});
await fireEvent.click(view.getByText("Choose responsible Subject"));
await waitFor(() => expect(view.queryByText("Loading Subjects…")).toBeNull());
// Current Subject is the observed reissue candidate; different-Subject native selection is covered in Playwright.
await fireEvent.click(view.getByText("Request again (new assignment)"));
await waitFor(() =>
expect(view.getByText(/Assignment operation saved: delegation-2/))
@@ -64,7 +64,7 @@ Deno.test("generic Ticket launch carries natural text without role or resource c
Deno.test("case, stable tags and principal filters survive every server-query page", () => {
const filters = readTicketFilters(
new URLSearchParams(
"q=options&case=open&tag=tag-1&tag=tag-2&principal=orchestrator",
"q=options&case=open&tag=tag-1&tag=tag-2&principal=subject&subject=subject-a",
),
);
for (const cursor of [null, "next-page"]) {
@@ -76,8 +76,8 @@ Deno.test("case, stable tags and principal filters survive every server-query pa
states: ["open"],
tag_ids: ["tag-1", "tag-2"],
assigned_principal: {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
kind: "subject",
subject_id: "subject-a",
},
handling_status: null,
sort: "updated_desc",
@@ -196,3 +196,24 @@ Deno.test("ticket detail keeps the operation rail outside main content", async (
);
assertEquals(css.includes("min-width: 0;"), true);
});
Deno.test("Subject identity survives every query page without singleton normalization", () => {
for (const subject of ["subject-a", "subject-b"]) {
const filters = readTicketFilters(
new URLSearchParams({ principal: "subject", subject }),
);
for (const cursor of [null, "next"]) {
assertEquals(ticketQueryRequest(filters, [], cursor).assigned_principal, {
kind: "subject",
subject_id: subject,
});
}
}
const legacy = readTicketFilters(
new URLSearchParams("principal=orchestrator"),
);
assertEquals(ticketQueryRequest(legacy, []).assigned_principal, {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
});
});
@@ -1,4 +1,3 @@
import type { BrowserWorkspaceOrchestratorResponse } from "#lib/generated/worker-launch-api.ts";
import type {
TicketAssignmentPrincipalSnapshot,
TicketAssignmentTrackingProjection,
@@ -11,7 +10,6 @@ import type {
export const TICKET_STATES = ["open", "closed"] as const;
export type TicketState = (typeof TICKET_STATES)[number];
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
export function ticketWorkerMessage(ticketId: string): string {
return `Work on Ticket ${ticketId}.`;
}
@@ -32,7 +30,8 @@ export function ticketWorkerLaunchHref(
}
export const HANDLING_STATES = ["waiting", "inprogress", "done"] as const;
export const ORCHESTRATOR_PRINCIPAL = {
// 読取り専用の旧割当filter。新規割当へは使用しない。
export const LEGACY_ORCHESTRATOR_PRINCIPAL = {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
} as const;
@@ -58,6 +57,7 @@ export function handlingLabel(
export function assignmentPrincipalLabel(
principal: TicketAssignmentPrincipalSnapshot,
): string {
if (principal.kind === "subject") return `Subject: ${principal.subject_id}`;
if (principal.kind === "workspace_agent") {
return `Workspace agent: ${principal.agent_key}`;
}
@@ -88,7 +88,8 @@ export type TicketFilters = {
query: string;
caseState: "all" | TicketState;
tagIds: string[];
principal: "all" | "orchestrator" | "user" | "worker";
principal: "all" | "subject" | "orchestrator" | "user" | "worker";
subjectId: string;
accountId: string;
runtimeId: string;
workerId: string;
@@ -109,10 +110,12 @@ export function readTicketFilters(search: URLSearchParams): TicketFilters {
query: search.get("q") ?? "",
caseState: state === "open" || state === "closed" ? state : "all",
tagIds: [...new Set(search.getAll("tag"))],
principal: principal === "orchestrator" || principal === "user" ||
principal: principal === "subject" || principal === "orchestrator" ||
principal === "user" ||
principal === "worker"
? principal
: "all",
subjectId: search.get("subject") ?? "",
accountId: search.get("account") ?? "",
runtimeId: search.get("runtime") ?? "",
workerId: search.get("worker") ?? "",
@@ -128,7 +131,15 @@ export function readTicketFilters(search: URLSearchParams): TicketFilters {
export function filterPrincipal(
filters: TicketFilters,
): TicketAssignmentPrincipalSnapshot | null {
if (filters.principal === "orchestrator") return ORCHESTRATOR_PRINCIPAL;
if (filters.principal === "subject") {
if (!filters.subjectId.trim()) {
throw new Error("Select a Subject for the assignment filter");
}
return { kind: "subject", subject_id: filters.subjectId.trim() };
}
if (filters.principal === "orchestrator") {
return LEGACY_ORCHESTRATOR_PRINCIPAL;
}
if (filters.principal === "user") {
if (!filters.accountId.trim()) {
throw new Error("Enter an account ID for the user principal filter");
@@ -327,3 +327,19 @@ Deno.test("mixed search compiles every supported filter and only remaining words
},
);
});
Deno.test("Subject search preserves exact identity, rejects blanks and conflicts", () => {
for (const subject of ["subject-a", "subject-b"]) {
assertEquals(
compileTicketSearch("assignee:subject:" + subject, tags)
.assigned_principal,
{ kind: "subject", subject_id: subject },
);
}
assertSearchError("assignee:subject:", "Subject identity");
assertSearchError('assignee:"subject: "', "Subject identity");
assertSearchError(
"assignee:subject:a assignee:subject:b",
"Conflicting assignee:",
);
});
@@ -1,7 +1,7 @@
import type {
TicketAssignmentPrincipalSnapshot,
TicketClosureReason,
TicketBrowseQueryRequest,
TicketClosureReason,
WorkspaceTag,
} from "../../generated/ticket-api.ts";
@@ -77,6 +77,13 @@ function tagId(value: string, tags: readonly WorkspaceTag[]): string {
}
function principal(value: string): TicketAssignmentPrincipalSnapshot {
if (value.startsWith("subject:")) {
const subject_id = value.slice("subject:".length);
if (!subject_id.trim()) {
throw new Error("Enter a Subject identity after subject:");
}
return { kind: "subject", subject_id };
}
if (value === "orchestrator") {
return { kind: "workspace_agent", agent_key: "workspace-orchestrator" };
}
@@ -90,7 +97,7 @@ function principal(value: string): TicketAssignmentPrincipalSnapshot {
}
}
throw new Error(
"Invalid assignee: value; use orchestrator, user/<account-id>, or worker/<runtime-id>/<worker-id>.",
"Invalid assignee: value; use subject:<subject-id>. Audit-only legacy filters: orchestrator, user/<account-id>, or worker/<runtime-id>/<worker-id>.",
);
}
@@ -3,7 +3,6 @@ import {
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "#lib/workspace/api/ticket-browser.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import {
HANDLING_STATES,
readTicketFilters,
@@ -17,7 +16,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
const workspaceId = params.workspaceId;
const filters = {
...readTicketFilters(url.searchParams),
principal: "orchestrator" as const,
tagIds: [],
closureReason: "all" as const,
closureText: "",
@@ -33,8 +32,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
null,
handling,
);
const [tickets, orchestrator] = await Promise.all([
loadJson(
const tickets = await loadJson(
fetch,
workspaceApiPath(workspaceId, "/tickets/query"),
{
@@ -44,13 +42,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
},
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
return { workspaceId, filters, handling, tickets, orchestrator };
);
return { workspaceId, filters, handling, tickets };
});
@@ -3,7 +3,7 @@
import { untrack } from "svelte";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY } from "#lib/workspace/api/ticket-browser.ts";
import { currentOrchestratorAssignment, handlingLabel, handlingSummary, ticketQueryRequest, closureReasonLabel } from "#lib/workspace/tickets/ticket-panel.ts";
import { currentOrchestratorAssignment, handlingLabel, handlingSummary, ticketQueryRequest, closureReasonLabel, assignmentPrincipalLabel } from "#lib/workspace/tickets/ticket-panel.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
@@ -39,23 +39,25 @@
}
</script>
<svelte:head><title>Orchestrator · {data.workspaceId}</title></svelte:head>
<svelte:head><title>Subject handling · {data.workspaceId}</title></svelte:head>
<div class="workspace-page orchestrator-page">
<header class="workspace-page-header"><h1>Orchestrator</h1><a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>Ticket cases</a></header>
<p>Principal: workspace-orchestrator · Worker availability: {data.orchestrator.data?.online ? "Online" : data.orchestrator.error ? "Unavailable" : "Offline"}</p>
{#if data.orchestrator.error}<p class="workspace-callout is-error" role="alert">Worker availability: {data.orchestrator.error}</p>{/if}
<form class="orchestrator-filters" aria-label="Orchestrator filters" onsubmit={applyFilters}>
<header class="workspace-page-header"><h1>Subject handling</h1><a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>Ticket cases</a></header>
<p>Responsibility belongs to each Subject, independently of its current Worker.</p>
<form class="orchestrator-filters" aria-label="Subject handling filters" onsubmit={applyFilters}>
<input type="hidden" name="principal" value={data.filters.subjectId ? "subject" : "all"} />
<label>Subject identity<input name="subject" value={data.filters.subjectId} placeholder="All Subjects" oninput={(event) => { const input = event.currentTarget.form?.elements.namedItem("principal") as HTMLInputElement | null; if (input) input.value = event.currentTarget.value.trim() ? "subject" : "all"; }} /></label>
<label>Search<input name="q" type="search" value={data.filters.query} /></label>
<label>Handling (self-reported)<select name="handling" value={data.handling ?? ""}><option value="">All including unknown</option><option value="waiting">Waiting</option><option value="inprogress">In progress</option><option value="done">Done</option></select></label>
<label>Ticket case<select name="case" value={data.filters.caseState}><option value="all">Open and closed</option><option value="open">Open</option><option value="closed">Closed</option></select></label>
<button class="workspace-secondary-button" disabled={busy}>{busy ? "Loading…" : "Apply filters"}</button>
</form>
<section class="orchestrator-assignments" aria-label="Current Orchestrator assignments">
<section class="orchestrator-assignments" aria-label="Current Subject assignments">
{#each response.data?.items ?? [] as ticket (ticket.id)}
{@const row = currentOrchestratorAssignment(ticket)}
<a class="orchestrator-assignment" href={ticketHref(data.workspaceId, ticket)}>
<div class="assignment-heading"><span>{ticket.resource_key}</span><strong>{ticket.title}</strong></div>
<div class="assignment-meta"><span>Handling: {handlingLabel(row?.tracking?.status)} ({row?.tracking ? "self-reported" : "not reported"})</span><span>Ticket case: {ticket.state}</span></div>
{#if row}<p>{assignmentPrincipalLabel(row.principal)}</p>{/if}
<p>{handlingSummary(row)}</p>
{#if row?.tracking?.next_action}<p>Next action: {row.tracking.next_action}</p>{/if}
{#if ticket.current_closure}<p>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
@@ -9,7 +9,6 @@
workspaceApiJsonWithBody,
workspaceApiPath,
} from "#lib/workspace/api/http.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import type { RepositoryListResponse } from "#lib/generated/repository-api.ts";
import {
@@ -27,7 +26,6 @@
import { ticketHref } from "#lib/workspace/resource-links.ts";
import {
currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, closureReasonLabel,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
@@ -56,9 +54,6 @@
searchBusy = false;
pageBusy = false;
navigationError = null;
orchestrator = { data: null, error: null };
orchestratorLoading = true;
orchestratorStarting = false;
repositoryResponse = { data: null, error: null };
repositoriesLoading = false;
creatingTicket = false;
@@ -67,13 +62,9 @@
createTitle = "";
createBody = "";
createTargets = [];
void loadOrchestrator(currentData, generation);
});
return () => { ++routeGeneration; };
});
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>({ data: null, error: null });
let orchestratorLoading = $state(true);
let orchestratorStarting = $state(false);
let creatingTicket = $state(false);
let createBusy = $state(false);
let createError = $state<string | null>(null);
@@ -100,16 +91,6 @@
return data === observedData && routeGeneration === generation;
}
async function loadOrchestrator(observedData: PageData, generation: number): Promise<void> {
const result = await loadJson(
fetch, workspaceApiPath(observedData.workspaceId, "/orchestrator"), undefined,
parseBrowserWorkspaceOrchestratorResponse,
);
if (!isCurrent(observedData, generation)) return;
orchestrator = result;
orchestratorLoading = false;
}
async function loadRepositories(): Promise<void> {
if (repositoriesLoading) return;
const observedData = data;
@@ -242,22 +223,6 @@
}
}
async function startOrchestrator() {
if (orchestratorLoading || orchestratorStarting || orchestrator.data?.online) return;
const observedData = data;
const generation = routeGeneration;
orchestratorStarting = true;
const result = await loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(observedData.workspaceId, "/orchestrator"),
{ method: "POST" },
parseBrowserWorkspaceOrchestratorResponse,
);
if (!isCurrent(observedData, generation)) return;
orchestrator = result;
orchestratorStarting = false;
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
@@ -280,23 +245,7 @@
type="button"
onclick={toggleCreate}
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
<div class="orchestrator-status" data-online={orchestrator.data?.online} aria-busy={orchestratorLoading}>
<span class="orchestrator-status-dot"></span>
<div>
<strong>Orchestrator</strong>
<span>{orchestratorLoading ? "Checking…" : orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
</div>
{#if orchestrator.data && !orchestrator.data.online}
<button
class="workspace-secondary-button"
type="button"
disabled={orchestratorStarting}
onclick={startOrchestrator}
>
{orchestratorStarting ? "Starting…" : "Start Orchestrator"}
</button>
{/if}
</div>
</div>
</header>
@@ -347,11 +296,6 @@
</form>
{/if}
{#if orchestrator.error}
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
</p>
{/if}
<form class="ticket-query-form" onsubmit={searchTickets} aria-label="Search tickets">
<input name="q" type="search" aria-label="Search tickets" bind:value={searchText}
@@ -364,11 +308,11 @@
<details class="ticket-search-help">
<summary>Search syntax</summary>
<p>Combine text with <code>state:open</code> or <code>state:closed</code>. Omit state to search both.</p>
<p><code>tag:"UI bug"</code> · <code>assignee:orchestrator</code> · <code>reason:completed</code></p>
<p><code>tag:"UI bug"</code> · <code>assignee:subject:SUBJECT_ID</code> · <code>reason:completed</code></p>
<p>Other principals: <code>assignee:user/account-id</code> or <code>assignee:worker/runtime-id/worker-id</code>.
Custom close reason: <code>reason:"Superseded plan"</code>.</p>
</details>
<a href={'/w/' + encodeURIComponent(data.workspaceId) + '/orchestrator'}>Orchestrator handling</a>
<a href={'/w/' + encodeURIComponent(data.workspaceId) + '/orchestrator'}>Subject handling</a>
</div>
{#if navigationError}<p class="workspace-callout is-error" role="alert">{navigationError}</p>{/if}
@@ -2,7 +2,6 @@ import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
import { error, redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import {
parseTicketDetail,
TICKET_BROWSER_API_LOAD_POLICY,
@@ -11,7 +10,6 @@ import {
canonicalResourceReference,
resourceKey,
} from "#lib/workspace/resource-links.ts";
import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
@@ -22,7 +20,7 @@ export const load = deferPage(
params.workspaceId,
`/tickets/${encodeURIComponent(reference)}`,
);
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
const [ticket, repositoriesRaw] = await Promise.all([
loadJson(
fetch,
ticketPath,
@@ -31,12 +29,6 @@ export const load = deferPage(
TICKET_BROWSER_API_LOAD_POLICY,
),
loadWorkspaceRepositoryList(fetch, params.workspaceId),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
if (!ticket.data) {
const message = ticket.error ?? "Ticket details could not be loaded";
@@ -70,7 +62,6 @@ export const load = deferPage(
ticketId: ticket.data?.id ?? reference,
ticket,
repositories,
orchestrator,
};
},
);
@@ -24,6 +24,7 @@
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
import {
relationLabel,
assignmentPrincipalLabel,
ticketWorkerLaunchHref,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type {
@@ -582,7 +583,7 @@
<BevelLine decorative />
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketHandling workspaceId={data.workspaceId} {ticket} orchestrator={data.orchestrator.data} refresh={refreshOperationTicket} />
<TicketHandling workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
<BevelLine decorative />
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
{/key}
@@ -696,15 +697,7 @@
{#each ticket.assignments as assignment}
<li>
<strong>{assignment.role}</strong>
<span>
{#if assignment.principal.kind === "worker"}
{assignment.principal.runtime_id}/{assignment.principal.worker_id}
{:else if assignment.principal.kind === "user"}
{assignment.principal.account_id}
{:else}
{assignment.principal.agent_key}
{/if}
</span>
<span>{assignmentPrincipalLabel(assignment.principal)}</span>
</li>
{/each}
</ul>
@@ -73,13 +73,14 @@ test("canonical initial post and discussion share markup and editing while all m
.toEqual(["ticket-thread-event", "divider", "ticket-thread-event", "divider", "ticket-thread-event"]);
expect(timeline.nextElementSibling).toBe(view.form);
expect(view.form.firstElementChild?.matches(".bevel-line")).toBe(true);
for (const selector of [".ticket-metadata", ".ticket-handling", ".ticket-tags", ".ticket-relations", ".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
for (const selector of [".ticket-metadata", ".ticket-handling", ".ticket-tags", ".ticket-relations", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(main.querySelector(selector)).toBeNull();
expect(rail.querySelector(selector)).not.toBeNull();
}
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
expect(view.queryByText("Delivery and execution")).toBeNull();
for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(rail.querySelector(".ticket-orchestrator-worker")).toBeNull();
for (const selector of [".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(rail.querySelector(selector)?.hasAttribute("open")).toBe(false);
}
expect(within(rail).getByText("Created by")).toBeTruthy();
@@ -132,7 +132,7 @@ afterEach(() => {
vi.clearAllMocks();
});
test("actual loader posts browse only; rows render while orchestrator is unresolved and repositories stay lazy", async () => {
test("actual loader posts browse only; no singleton status request or startup UI and repositories stay lazy", async () => {
const rows = deferred<Response>();
const orchestrator = deferred<Response>();
const repos = deferred<Response>();
@@ -151,19 +151,9 @@ test("actual loader posts browse only; rows render while orchestrator is unresol
rows.resolve(Response.json(browse()));
const view = render(TicketsPage, { data: await pending });
expect(await screen.findByText("First ticket")).toBeTruthy();
expect(screen.getByText("Checking…")).toBeTruthy();
expect(
view.container.querySelector(".orchestrator-status")?.getAttribute(
"aria-busy",
),
).toBe("true");
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Orchestrator" }))
.toBeNull();
expect(request.mock.calls.map(([path]) => path)).toEqual([
"/api/w/workspace-a/tickets/browse",
"/api/w/workspace-a/orchestrator",
]);
expect(view.container.querySelector(".orchestrator-status")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Orchestrator" })).toBeNull();
expect(request.mock.calls.map(([path]) => path)).toEqual(["/api/w/workspace-a/tickets/browse"]);
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
expect(
request.mock.calls.filter(([path]) =>
@@ -174,7 +164,7 @@ test("actual loader posts browse only; rows render while orchestrator is unresol
expect(screen.getByText("First ticket")).toBeTruthy();
repos.resolve(Response.json(repositories()));
orchestrator.resolve(Response.json(status()));
expect(await screen.findByText("Online")).toBeTruthy();
expect(screen.queryByText("Online")).toBeNull();
});
test("lazy repository HTTP failure is visible and retry enables targetless creation without reloading tickets", async () => {
@@ -224,19 +214,13 @@ test("lazy repository HTTP failure is visible and retry enables targetless creat
.toBe(false);
});
test("failed status is Unavailable, not Offline, and does not hide rows", async () => {
const orchestrator = deferred<Response>();
requests({ "/api/w/workspace-a/orchestrator": () => orchestrator.promise });
test("no singleton status is read even if the compatibility endpoint would fail", async () => {
const request = requests({ "/api/w/workspace-a/orchestrator": async () => { throw new Error("No singleton"); } });
render(TicketsPage, { data: data() });
expect(screen.getByText("Checking…")).toBeTruthy();
orchestrator.reject(new Error("Controlled status failure"));
expect(await screen.findByText("Unavailable")).toBeTruthy();
expect(screen.getByText("Orchestrator status: Controlled status failure"))
.toBeTruthy();
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Orchestrator" }))
.toBeNull();
await flushResponses();
expect(request).not.toHaveBeenCalled();
expect(screen.getByText("First ticket")).toBeTruthy();
expect(screen.queryByText("Checking…")).toBeNull();
});
test("late prior-Workspace status and repository responses cannot replace current state or finish its pending load", async () => {
@@ -267,7 +251,7 @@ test("late prior-Workspace status and repository responses cannot replace curren
oldStatus.resolve(Response.json(status("workspace-a", false)));
oldRepos.resolve(Response.json(repositories("workspace-a")));
await flushResponses();
expect(screen.getByText("Checking…")).toBeTruthy();
expect(screen.queryByText("Checking…")).toBeNull();
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.getByText("Loading repositories…")).toBeTruthy();
expect(
@@ -277,7 +261,7 @@ test("late prior-Workspace status and repository responses cannot replace curren
expect(screen.getByText("Current Workspace ticket")).toBeTruthy();
newStatus.resolve(Response.json(status("workspace-b")));
newRepos.resolve(Response.json(repositories("workspace-b")));
expect(await screen.findByText("Online")).toBeTruthy();
expect(screen.queryByText("Online")).toBeNull();
await waitFor(() =>
expect(
(screen.getByRole("button", {
@@ -29,7 +29,9 @@ Deno.test("ticket child operations reset across Workspaces even when the retaine
const keyedChildren = [...pageSource.matchAll(
/\{#key JSON\.stringify\(\[data\.workspaceId, ticket\.id\]\)\}([\s\S]*?)\{\/key\}/g,
)].map((match) => match[1]);
for (const child of ["TicketTags", "TicketHandling", "TicketWorkerObservation"]) {
for (
const child of ["TicketTags", "TicketHandling", "TicketWorkerObservation"]
) {
assert(
keyedChildren.some((block) => block.includes(`<${child}`)),
`${child} must reset for the Workspace and Ticket, whether in main or rail`,
@@ -63,8 +65,20 @@ Deno.test("ticket detail fences stale mutation responses", () => {
Deno.test("ticket responsibility display does not claim Worker liveness or work authority", () => {
assertStringIncludes(pageSource, "Role assignments and manual Workers");
assertStringIncludes(pageSource, "Assignments are retained independently of Worker state.");
assertStringIncludes(pageSource, "assignment.principal.runtime_id");
assertStringIncludes(pageSource, "assignment.principal.worker_id");
assertStringIncludes(
pageSource,
"Assignments are retained independently of Worker state.",
);
assertStringIncludes(
pageSource,
"assignmentPrincipalLabel(assignment.principal)",
);
const modelSource = Deno.readTextFileSync(
new URL("../src/lib/workspace/tickets/ticket-panel.ts", import.meta.url),
);
assertStringIncludes(modelSource, 'principal.kind === "subject"');
assertStringIncludes(modelSource, "principal.subject_id");
assertStringIncludes(modelSource, "principal.runtime_id");
assertStringIncludes(modelSource, "principal.worker_id");
assert(!pageSource.includes("No active role assignment."));
});
@@ -0,0 +1,143 @@
{
"schemaVersion": 1,
"id": "subject-handling",
"title": "Ticket Subject responsibility, separate from Worker execution",
"baseUrl": "http://127.0.0.1:15371",
"colorScheme": "dark",
"reducedMotion": "reduce",
"personas": [
{
"id": "member",
"label": "Synthetic authenticated member, not settings owner",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "principal",
"label": "Choose responsibility",
"path": "/w/tickets/tickets/T-2",
"goal": "Inspect handling and choose responsible Subject",
"dataState": "Open and waiting with current assignment, no current body Worker",
"ready": {
"kind": "selector",
"selector": ".ticket-handling"
},
"capturePoints": [
{
"id": "choice",
"label": "Observed assignment and choice",
"interaction": [
{
"action": "click",
"selector": ".assignment-principal summary"
}
],
"fullPage": true
}
]
},
{
"id": "legacy",
"path": "/w/tickets-legacy/tickets/T-2",
"goal": "Inspect retained legacy assignment audit",
"dataState": "Legacy assignment; requires explicit Subject reissue",
"ready": {
"kind": "selector",
"selector": ".ticket-handling"
},
"capturePoints": [
{
"id": "audit",
"label": "Legacy audit and no implicit startup",
"fullPage": true
}
],
"label": "Inspect retained legacy assignment audit"
},
{
"id": "handling",
"path": "/w/tickets/orchestrator",
"goal": "Inspect handling across Subjects",
"dataState": "Multiple Ticket cases and self-reported handling",
"ready": {
"kind": "selector",
"selector": ".orchestrator-assignments"
},
"capturePoints": [
{
"id": "list",
"label": "Responsibility list",
"fullPage": true
}
],
"label": "Inspect handling across Subjects"
},
{
"id": "cases",
"path": "/w/tickets/tickets",
"goal": "Find Ticket cases without singleton startup",
"dataState": "Ticket cases including open and closed",
"ready": {
"kind": "selector",
"selector": ".ticket-panel-page"
},
"capturePoints": [
{
"id": "list",
"label": "Ticket case list",
"fullPage": true
}
],
"label": "Find Ticket cases without singleton startup"
}
],
"processes": [
{
"id": "ticket-fixture",
"command": "deno",
"cwd": "../../../../tools/web-ux",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/ticket_fixture_server.ts",
"15372",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15372/health"
},
{
"id": "subject-fixture-proxy",
"command": "deno",
"cwd": "../..",
"args": [
"run",
"--allow-net",
"test/visual-review/subject-handling-fixture-server.ts",
"15371",
"http://127.0.0.1:15372"
],
"readyUrl": "http://127.0.0.1:15371/health"
}
]
}
@@ -0,0 +1,156 @@
// 共有Ticket fixtureの古い認証・担当projectionだけをAPI境界で更新する。
// 製品shell/CSS・認可処理には手を加えず、実Backend/Worker実行の証拠にはしない。
const port = Number(Deno.args[0]);
const upstream = Deno.args[1];
const baseline = Deno.args.includes("--baseline");
if (!Number.isInteger(port) || !upstream) {
throw new Error("usage: server <port> <upstream> [--baseline]");
}
const subject = (id: string, state = "active") => ({
id,
role: "Research",
state,
behavior_md: "",
memory_fingerprint: "fixture",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
current_worker: null,
});
function project(value: unknown): unknown {
if (Array.isArray(value)) return value.map(project);
if (value && typeof value === "object") {
const item = value as Record<string, unknown>;
if (
item.kind === "workspace_agent" &&
item.agent_key === "workspace-orchestrator"
) return { kind: "subject", subject_id: "subject-a" };
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, project(value)]),
);
}
return value;
}
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (
url.pathname.endsWith("/protocol/ws") &&
request.headers.get("upgrade") === "websocket"
) {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onmessage = (event) => {
const frame = JSON.parse(String(event.data));
if (frame?.message?.method === "subscribe_events") {
socket.send(JSON.stringify({
protocol_version: 2,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: frame.message.params.request_id,
subscription_id: "fixture-workers",
selector: frame.message.params.selector,
snapshot: { topic: "workers", data: { workers: [] } },
},
},
}));
}
};
return response;
}
if (url.pathname === "/api/auth/whoami") {
return Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
user_id: "fixture-user",
account_id: "fixture-member",
handle: "mika",
display_name: "Mika",
auth_method: "browser_session",
},
});
}
if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: false });
}
if (url.pathname.endsWith("/subjektiv/subjects")) {
if (url.pathname.includes("tickets-denied")) {
return Response.json({ message: "Subject listing denied" }, {
status: 403,
});
}
if (url.pathname.includes("tickets-empty")) {
return Response.json({
limit: 100,
items: [],
has_more: false,
next_cursor: null,
});
}
const more = url.searchParams.has("cursor");
return Response.json({
limit: 100,
items: more
? [
subject("subject-a"),
subject("subject-b"),
subject("subject-retired", "retired"),
]
: [subject("subject-a")],
has_more: !more,
next_cursor: more ? null : "next-subject",
});
}
if (url.pathname.endsWith("/tickets/browse")) {
const input = await request.json();
const queryUrl = new URL(
url.pathname.replace(/\/browse$/, "/query"),
upstream,
);
const response = await fetch(queryUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) return response;
const data = await response.json();
data.items = data.items.map((item: Record<string, unknown>) =>
Object.fromEntries(
[
"id",
"resource_key",
"title",
"state",
"updated_at",
"tags",
"current_closure",
"assignment_tracking",
].map((key) => [key, item[key]]),
)
);
return Response.json(baseline ? data : project(data));
}
const response = await fetch(new URL(url.pathname + url.search, upstream), {
method: request.method,
headers: request.headers,
body: request.method === "GET" || request.method === "HEAD"
? undefined
: await request.arrayBuffer(),
redirect: "manual",
});
if (url.pathname.endsWith("/workspace") && response.ok) {
const value = await response.json();
value.permissions.delete_subjects = false;
return Response.json(value);
}
if (
!baseline && !url.pathname.includes("tickets-legacy") &&
/(?:\/api\/w|\/workspaces)\/[^/]+\/tickets/.test(url.pathname) &&
response.headers.get("content-type")?.includes("application/json")
) {
return Response.json(project(await response.json()), {
status: response.status,
});
}
return response;
});
@@ -0,0 +1,143 @@
{
"schemaVersion": 1,
"id": "subject-handling",
"title": "Ticket Subject responsibility, separate from Worker execution",
"baseUrl": "http://127.0.0.1:15371",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "member",
"label": "Synthetic authenticated member, not settings owner",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "principal",
"label": "Choose responsibility",
"path": "/w/tickets/tickets/T-2",
"goal": "Inspect handling and choose responsible Subject",
"dataState": "Open and waiting with current assignment, no current body Worker",
"ready": {
"kind": "selector",
"selector": ".ticket-handling"
},
"capturePoints": [
{
"id": "choice",
"label": "Observed assignment and choice",
"interaction": [
{
"action": "click",
"selector": ".assignment-principal summary"
}
],
"fullPage": true
}
]
},
{
"id": "legacy",
"path": "/w/tickets-legacy/tickets/T-2",
"goal": "Inspect retained legacy assignment audit",
"dataState": "Legacy assignment; requires explicit Subject reissue",
"ready": {
"kind": "selector",
"selector": ".ticket-handling"
},
"capturePoints": [
{
"id": "audit",
"label": "Legacy audit and no implicit startup",
"fullPage": true
}
],
"label": "Inspect retained legacy assignment audit"
},
{
"id": "handling",
"path": "/w/tickets/orchestrator",
"goal": "Inspect handling across Subjects",
"dataState": "Multiple Ticket cases and self-reported handling",
"ready": {
"kind": "selector",
"selector": ".orchestrator-assignments"
},
"capturePoints": [
{
"id": "list",
"label": "Responsibility list",
"fullPage": true
}
],
"label": "Inspect handling across Subjects"
},
{
"id": "cases",
"path": "/w/tickets/tickets",
"goal": "Find Ticket cases without singleton startup",
"dataState": "Ticket cases including open and closed",
"ready": {
"kind": "selector",
"selector": ".ticket-panel-page"
},
"capturePoints": [
{
"id": "list",
"label": "Ticket case list",
"fullPage": true
}
],
"label": "Find Ticket cases without singleton startup"
}
],
"processes": [
{
"id": "ticket-fixture",
"command": "deno",
"cwd": "../../../../tools/web-ux",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/ticket_fixture_server.ts",
"15372",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15372/health"
},
{
"id": "subject-fixture-proxy",
"command": "deno",
"cwd": "../..",
"args": [
"run",
"--allow-net",
"test/visual-review/subject-handling-fixture-server.ts",
"15371",
"http://127.0.0.1:15372"
],
"readyUrl": "http://127.0.0.1:15371/health"
}
]
}
@@ -0,0 +1,289 @@
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { chromium } from "npm:playwright@1.59.1";
import { loadScenario } from "../../../../tools/web-ux/src/scenario.ts";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
// 実production buildのnative selectとloopback fixtureだけを検証する。
// 実Backend認可、Worker起動、通知配送の成功とは区別する。
Deno.test("Subject selection, paging, audit, permission failure and exact retry without singleton startup", async () => {
const scenarioPath =
new URL("./subject-handling-light.json", import.meta.url).pathname;
const scenario = await loadScenario(scenarioPath);
function freePort() {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
const upstream = freePort();
let proxy = freePort();
while (upstream === proxy) proxy = freePort();
scenario.processes![0].args![4] = String(upstream);
scenario.processes![0].readyUrl = `http://127.0.0.1:${upstream}/health`;
scenario.processes![1].args![3] = String(proxy);
scenario.processes![1].args![4] = `http://127.0.0.1:${upstream}`;
scenario.processes![1].readyUrl = `http://127.0.0.1:${proxy}/health`;
const base = `http://127.0.0.1:${proxy}`;
const output = new URL(
"../../../../target/web-ux/t778-validation/browser/",
import.meta.url,
).pathname;
await Deno.mkdir(output, { recursive: true });
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
output + "/process-logs",
[],
);
const browser = await chromium.launch({ headless: true });
const captures: unknown[] = [];
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
let documentStatus: number | null = null;
page.on("response", (response) => {
if (response.request().isNavigationRequest()) {
documentStatus = response.status();
}
});
const errors: string[] = [];
const pageErrors: string[] = [];
const requestFailures: string[] = [];
page.on("requestfailed", (request) => {
requestFailures.push(
`${request.method()} ${
new URL(request.url()).pathname
}: ${request.failure()?.errorText}`,
);
});
async function captureState(state: string, expectedFailure = false) {
assertEquals(pageErrors, []);
const image = `${colorScheme}-${width}-${state}.png`;
const bytes = await page.screenshot({
path: output + image,
fullPage: true,
});
const sha256 = Array.from(
new Uint8Array(
await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)),
),
).map((byte) => byte.toString(16).padStart(2, "0")).join("");
captures.push({
image,
sha256,
route: page.url(),
documentStatus,
colorScheme,
width,
expectedFailure,
errors: [...errors],
pageErrors: [...pageErrors],
requestFailures: [...requestFailures],
horizontalOverflow: await page.evaluate(
"document.documentElement.scrollWidth > innerWidth",
),
snapshot: await page.locator("main").first().ariaSnapshot(),
});
}
const paths: string[] = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("request", (request) => {
paths.push(new URL(request.url()).pathname);
});
await page.goto(base + "/w/tickets/tickets/T-2");
await page.getByText("Self-reported handling: waiting", {
exact: true,
}).waitFor();
await page.getByText("Choose responsible Subject", { exact: true })
.click();
const select = page.getByRole("combobox", {
name: "Subject",
exact: true,
});
await select.selectOption("subject-a");
await page.getByRole("button", { name: "More Subjects", exact: true })
.click();
await page.locator('option[value="subject-b"]').waitFor({
state: "attached",
});
assertEquals(
await select.locator('option[value="subject-a"]').count(),
1,
);
assert(
await select.locator('option[value="subject-retired"]').evaluate((
option,
) => (option as unknown as { disabled: boolean }).disabled),
await select.innerHTML(),
);
// Native select remains keyboard operable after paging.
await select.focus();
await select.press("Home");
await select.press("ArrowDown");
assertEquals(await select.inputValue(), "subject-a");
await select.press("ArrowDown");
assertEquals(await select.inputValue(), "subject-b");
assert(
await page.evaluate("document.activeElement?.tagName === 'SELECT'"),
);
await select.scrollIntoViewIfNeeded();
await captureState("selection");
assertEquals(
await page.evaluate(
"document.documentElement.scrollWidth > innerWidth",
),
false,
);
assertEquals(errors, []);
assertEquals(requestFailures, []);
const sent: string[] = [];
await page.route("**/assignments/orchestrator", async (route) => {
if (route.request().method() === "PUT") {
sent.push(route.request().postData()!);
await route.abort("failed");
} else await route.continue();
});
await page.getByText("Replace this delegation", { exact: true })
.click();
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", {
name: "Replace assignment",
exact: true,
}).click();
await page.getByRole("alert").filter({ hasText: "Outcome unknown" })
.waitFor();
assertEquals(sent.length, 1);
const input = JSON.parse(sent[0]);
assertEquals(input.principal, {
kind: "subject",
subject_id: "subject-b",
});
assertEquals(input.expected_assignment_id, "assignment-T-2");
assert(await select.isDisabled());
await page.getByRole("alert").filter({ hasText: "Outcome unknown" })
.scrollIntoViewIfNeeded();
await captureState("unknown", true);
assertEquals(errors, ["Failed to load resource: net::ERR_FAILED"]);
assertEquals(requestFailures.length, 1);
assert(requestFailures[0].startsWith("PUT /api/w/tickets/tickets/"));
assert(
requestFailures[0].endsWith(
"/assignments/orchestrator: net::ERR_FAILED",
),
);
const failed = page.waitForEvent(
"requestfailed",
(r) =>
r.method() === "PUT" &&
r.url().endsWith("/assignments/orchestrator"),
);
await page.getByRole("button", {
name: "Retry exact operation",
exact: true,
}).click();
await failed;
assertEquals(sent.length, 2);
assertEquals(sent[1], sent[0]);
assertEquals(
paths.filter((path) => /\/api\/w\/[^/]+\/orchestrator/.test(path)),
[],
);
await page.unroute("**/assignments/orchestrator");
for (const persona of ["legacy", "readonly", "empty", "denied"]) {
errors.length = 0;
requestFailures.length = 0;
await page.goto(`${base}/w/tickets-${persona}/tickets/T-2`);
await page.locator(".ticket-handling").waitFor();
if (persona === "legacy") {
await page.getByText(/Legacy assignment retained for audit only/)
.scrollIntoViewIfNeeded();
assertEquals(
await page.getByRole("textbox", {
name: "Answer / continue this handling",
}).count(),
0,
);
} else if (persona === "readonly") {
assertEquals(
await page.getByText("Choose responsible Subject", {
exact: true,
}).count(),
0,
);
} else {
await page.getByText("Choose responsible Subject", {
exact: true,
}).click();
if (persona === "empty") {
await page.getByText(/No Subjects available/).waitFor();
} else {await page.getByRole("button", { name: "Retry Subjects" })
.waitFor();}
await page.getByText("Replace this delegation", { exact: true })
.click();
assert(
await page.getByRole("button", {
name: "Replace assignment",
exact: true,
}).isDisabled(),
);
await page.getByRole("button", {
name: "Replace assignment",
exact: true,
}).scrollIntoViewIfNeeded();
}
if (persona === "readonly") {
await page.locator(".ticket-handling").scrollIntoViewIfNeeded();
}
await captureState(persona, persona === "denied");
assertEquals(
await page.evaluate(
"document.documentElement.scrollWidth > innerWidth",
),
false,
);
if (persona !== "denied") {
assertEquals(errors, []);
assertEquals(requestFailures, []);
} else {
assertEquals(errors, [
"Failed to load resource: the server responded with a status of 403 (Forbidden)",
]);
assertEquals(requestFailures, [
"GET /api/w/tickets-denied/subjektiv/subjects: net::ERR_ABORTED",
]);
}
}
} finally {
await context.close();
}
}
}
} finally {
await Deno.writeTextFile(
output + "/review-context.json",
JSON.stringify(
{
source: "production Web build, synthetic API boundary only",
captures,
},
null,
2,
),
);
await browser.close();
await stopOwnedProcesses(processes);
}
});