feat: separate Ticket case state and immutable closure judgments

This commit is contained in:
2026-10-11 01:41:46 +09:00
parent 6a12b6cc34
commit ba3ebaa68c
65 changed files with 7625 additions and 6844 deletions
+800
View File
@@ -0,0 +1,800 @@
//! Public Ticket case authority contracts over isolated SQLite stores.
use rusqlite::{Connection, params};
use std::collections::BTreeMap;
use std::sync::{Arc, Barrier};
use tempfile::TempDir;
use ticket::*;
fn backend(temp: &TempDir) -> SqliteTicketBackend {
SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap()
}
fn request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
TicketCaseUpdate {
expected_content_digest: ticket_content_digest(ticket),
expected_state: ticket.meta.workflow_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: key.into(),
action,
explanation: "A reasoned judgment.\n\nNot an execution milestone.".into(),
references: vec![TicketReference {
kind: "result".into(),
target: "document:example".into(),
}],
author: Some("human:alice".into()),
}
}
fn close(reason: TicketClosureReason) -> TicketCaseAction {
TicketCaseAction::Close { reason }
}
fn create(backend: &SqliteTicketBackend) -> Ticket {
let mut input = NewTicket::new("Independent case");
input.assignee = Some("historical-assignee".into());
backend.create_with_snapshot(input).unwrap().1
}
#[test]
fn close_reopen_and_correction_preserve_reason_actor_references_and_identity() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::NotPlanned)),
)
.unwrap();
assert!(closed.meta.targets.is_empty());
assert_eq!(closed.meta.workflow_state, TicketState::Closed);
let first_judgment = closed.current_closure.clone().unwrap();
assert_eq!(first_judgment.reason, TicketClosureReason::NotPlanned);
assert_eq!(first_judgment.author, "human:alice");
assert_eq!(
first_judgment.references,
request(&initial, "close", close(TicketClosureReason::NotPlanned)).references
);
let corrected = store
.update_case(
&initial.meta.id,
request(
&closed,
"correct",
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Duplicate,
},
),
)
.unwrap();
assert_eq!(
corrected.current_closure.as_ref().unwrap().reason,
TicketClosureReason::Duplicate
);
assert_eq!(corrected.case_history[0], closed.case_history[0]);
assert_eq!(
corrected.case_history[1]
.previous_closure_operation_key
.as_deref(),
Some("close")
);
let reopened = store
.update_case(
&initial.meta.id,
request(&corrected, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
assert_eq!(reopened.meta.workflow_state, TicketState::Open);
assert!(reopened.current_closure.is_none());
assert!(reopened.resolution.is_none());
assert_eq!(reopened.closure_operation_key.as_deref(), Some("correct"));
assert_eq!(reopened.case_history.len(), 3);
assert_eq!(reopened.meta.assignee, initial.meta.assignee);
assert_eq!(reopened.meta.resource_key, initial.meta.resource_key);
assert_eq!(
&reopened.events[..initial.events.len()],
initial.events.as_slice()
);
assert_eq!(
store.show(initial.meta.id.clone().into()).unwrap(),
reopened
);
}
#[test]
fn closure_reasons_round_trip_without_repository_or_merge_request_prerequisites() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
for reason in [
TicketClosureReason::Completed,
TicketClosureReason::NotPlanned,
TicketClosureReason::Duplicate,
TicketClosureReason::Unknown,
TicketClosureReason::Other("Custom decision".into()),
] {
let initial = create(&store);
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(reason.clone())),
)
.unwrap();
assert_eq!(closed.current_closure.unwrap().reason, reason);
}
}
#[test]
fn case_cas_rejects_stale_content_state_and_closure_key_without_writes() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let base = request(&initial, "close", close(TicketClosureReason::Completed));
let mut stale = base.clone();
stale.expected_content_digest = "stale".into();
assert!(matches!(
store.update_case(&initial.meta.id, stale),
Err(TicketError::StaleContent { .. })
));
let mut stale = base.clone();
stale.expected_state = TicketState::Closed;
assert!(matches!(
store.update_case(&initial.meta.id, stale),
Err(TicketError::StaleWorkflowState { .. })
));
let mut stale = base;
stale.expected_closure_operation_key = Some("unobserved".into());
assert!(matches!(
store.update_case(&initial.meta.id, stale),
Err(TicketError::StaleClosureOperationKey { .. })
));
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
}
#[test]
fn closure_keys_protect_closed_correction_and_open_closed_open_aba() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let stale_open = request(
&initial,
"late-close",
close(TicketClosureReason::Completed),
);
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::Unknown)),
)
.unwrap();
let stale_correction = request(
&closed,
"late-correct",
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Completed,
},
);
let corrected = store
.update_case(
&initial.meta.id,
request(
&closed,
"correct",
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::NotPlanned,
},
),
)
.unwrap();
assert!(matches!(
store.update_case(&initial.meta.id, stale_correction),
Err(TicketError::StaleClosureOperationKey { .. })
));
let reopened = store
.update_case(
&initial.meta.id,
request(&corrected, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
assert_eq!(
ticket_content_digest(&reopened),
ticket_content_digest(&initial)
);
assert!(matches!(
store.update_case(&initial.meta.id, stale_open),
Err(TicketError::StaleClosureOperationKey { .. })
));
assert_eq!(
store.show(initial.meta.id.clone().into()).unwrap(),
reopened
);
}
#[test]
fn exact_replay_after_edit_reopen_and_backend_restart_does_not_reapply_a_close() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
let closed = store
.update_case(&initial.meta.id, operation.clone())
.unwrap();
let reopened = store
.update_case(
&initial.meta.id,
request(&closed, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
let edited = store
.edit_item(
initial.meta.id.clone().into(),
TicketItemEdit {
title: Some("Changed requirements".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(edited.case_history, reopened.case_history);
let restored =
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "workspace").unwrap();
assert_eq!(
restored.update_case(&initial.meta.id, operation).unwrap(),
edited
);
}
#[test]
fn replay_fingerprint_binds_every_request_field_and_authenticated_source_identity() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
let closed = store
.update_case(&initial.meta.id, operation.clone())
.unwrap();
let mut changed = Vec::new();
let mut x = operation.clone();
x.expected_content_digest.push('x');
changed.push(x);
let mut x = operation.clone();
x.expected_state = TicketState::Closed;
changed.push(x);
let mut x = operation.clone();
x.expected_closure_operation_key = Some("close".into());
changed.push(x);
let mut x = operation.clone();
x.action = close(TicketClosureReason::NotPlanned);
changed.push(x);
let mut x = operation.clone();
x.action = TicketCaseAction::Reopen;
changed.push(x);
let mut x = operation.clone();
x.explanation.push('x');
changed.push(x);
let mut x = operation.clone();
x.references.clear();
changed.push(x);
let mut x = operation.clone();
x.author = None;
changed.push(x);
for input in changed {
assert!(matches!(
store.update_case(&initial.meta.id, input),
Err(TicketError::OperationFingerprintMismatch { .. })
));
}
let other_source = store.clone().with_event_attributes(BTreeMap::from([(
"source_runtime_id".into(),
"other-runtime".into(),
)]));
assert!(matches!(
other_source.update_case(&initial.meta.id, operation),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
}
#[test]
fn invalid_action_does_not_close_correct_or_reopen_the_wrong_case() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
for action in [
TicketCaseAction::Reopen,
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Completed,
},
] {
assert!(
store
.update_case(&initial.meta.id, request(&initial, "invalid", action))
.is_err()
);
}
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::Unknown)),
)
.unwrap();
assert!(
store
.update_case(
&initial.meta.id,
request(
&closed,
"double-close",
close(TicketClosureReason::Completed)
)
)
.is_err()
);
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
}
#[test]
fn custom_reason_limits_are_utf8_bytes_and_validation_keeps_long_markdown_explanations() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
for reason in [
"".into(),
" \n".into(),
"bad\0reason".into(),
"あ".repeat(342),
] {
assert!(
store
.update_case(
&initial.meta.id,
request(&initial, "bad", close(TicketClosureReason::Other(reason)))
)
.is_err()
);
}
let mut valid = request(
&initial,
"close",
close(TicketClosureReason::Other("x".repeat(1024))),
);
valid.explanation = format!(
"## Decision\n{}\n<!-- typed SQLite text -->",
"long".repeat(8000)
);
let closed = store.update_case(&initial.meta.id, valid.clone()).unwrap();
assert_eq!(
closed.current_closure.as_ref().unwrap().explanation,
valid.explanation
);
assert_eq!(store.update_case(&initial.meta.id, valid).unwrap(), closed);
}
#[test]
fn blank_explanation_actor_reference_or_key_rejects_without_recording_receipts() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let base = request(&initial, "key", close(TicketClosureReason::Unknown));
let mut cases = Vec::new();
let mut x = base.clone();
x.explanation = " ".into();
cases.push(x);
let mut x = base.clone();
x.explanation = "bad\0text".into();
cases.push(x);
let mut x = base.clone();
x.author = Some(" ".into());
cases.push(x);
let mut x = base.clone();
x.references[0].target.clear();
cases.push(x);
let mut x = base.clone();
x.references[0].kind.clear();
cases.push(x);
let mut x = base;
x.operation_key.clear();
cases.push(x);
for input in cases {
assert!(store.update_case(&initial.meta.id, input).is_err());
}
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
}
#[test]
fn case_history_is_immutable_in_sqlite_and_hook_failure_rolls_back_every_case_write() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let rejected = store.clone().with_mutation_hook(Arc::new(|_, _| {
Err(TicketError::Conflict("hook rejected".into()))
}));
let operation = request(&initial, "close", close(TicketClosureReason::Unknown));
assert!(
rejected
.update_case(&initial.meta.id, operation.clone())
.is_err()
);
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
let closed = store.update_case(&initial.meta.id, operation).unwrap();
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
for sql in [
"UPDATE typed_ticket_case_history SET judgment_json = '{}'",
"DELETE FROM typed_ticket_case_history",
] {
assert!(conn.execute(sql, []).is_err());
}
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
}
#[test]
fn concurrent_exact_replay_creates_one_judgment_and_competing_closes_have_one_winner() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let barrier = Arc::new(Barrier::new(2));
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
let mut joins = Vec::new();
for _ in 0..2 {
let store = store.clone();
let barrier = barrier.clone();
let operation = operation.clone();
let id = initial.meta.id.clone();
joins.push(std::thread::spawn(move || {
barrier.wait();
store.update_case(&id, operation)
}));
}
for join in joins {
join.join().unwrap().unwrap();
}
assert_eq!(
store
.show(initial.meta.id.clone().into())
.unwrap()
.case_history
.len(),
1
);
let initial = create(&store);
let barrier = Arc::new(Barrier::new(2));
let mut joins = Vec::new();
for key in ["winner-a", "winner-b"] {
let store = store.clone();
let barrier = barrier.clone();
let operation = request(&initial, key, close(TicketClosureReason::Unknown));
let id = initial.meta.id.clone();
joins.push(std::thread::spawn(move || {
barrier.wait();
store.update_case(&id, operation)
}));
}
let outcomes: Vec<_> = joins.into_iter().map(|j| j.join().unwrap()).collect();
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
assert!(
outcomes
.iter()
.any(|result| matches!(result, Err(TicketError::StaleWorkflowState { .. })))
);
assert_eq!(
store
.show(initial.meta.id.clone().into())
.unwrap()
.case_history
.len(),
1
);
}
#[test]
fn queue_and_readiness_are_routing_audit_and_never_change_case_or_reopen_closed() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let ready = store
.mark_ready(
initial.meta.id.clone().into(),
TicketMarkReady {
operation_key: "ready".into(),
reason: None,
intake_summary: None,
author: None,
},
)
.unwrap();
assert_eq!(ready.meta.workflow_state, TicketState::Open);
assert_eq!(ready.meta.readiness.as_deref(), Some("ready"));
store
.queue_ready(initial.meta.id.clone().into(), "router")
.unwrap();
let queued = store.show(initial.meta.id.clone().into()).unwrap();
assert_eq!(queued.meta.workflow_state, TicketState::Open);
assert_eq!(queued.meta.queued_by.as_deref(), Some("router"));
let closed = store
.update_case(
&initial.meta.id,
request(&queued, "close", close(TicketClosureReason::NotPlanned)),
)
.unwrap();
assert!(
store
.queue_ready(initial.meta.id.clone().into(), "router")
.is_err()
);
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
}
#[test]
fn workspace_boundary_rejects_other_workspace_ticket_and_shared_operation_dispatch_uses_case_api() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let other =
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "other-workspace")
.unwrap();
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
assert!(matches!(
other.update_case(&initial.meta.id, operation.clone()),
Err(TicketError::NotFound(_))
));
let output = execute_ticket_backend_operation(
&store,
TicketBackendOperation::UpdateCase {
ticket: initial.meta.id.clone(),
request: operation,
},
)
.unwrap();
assert!(
matches!(output, TicketBackendOperationResult::Ticket(ticket) if ticket.meta.workflow_state == TicketState::Closed)
);
}
#[test]
fn historical_events_and_merge_request_evidence_are_not_rewritten_by_case_judgments() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let historical = store.clone().with_event_attributes(BTreeMap::from([
("operation_key".into(), "old-completion".into()),
("merge_request_id".into(), "MR-existing".into()),
]));
let mut event = NewTicketEvent::new(
TicketEventKind::Other("ticket_completion".into()),
"Historic completed result",
);
event.references = vec![TicketReference {
kind: "merge_request".into(),
target: "MR-existing".into(),
}];
historical
.add_event(initial.meta.id.clone().into(), event)
.unwrap();
let before = store.show(initial.meta.id.clone().into()).unwrap();
let after = store
.update_case(
&initial.meta.id,
request(&before, "new-close", close(TicketClosureReason::NotPlanned)),
)
.unwrap();
assert_eq!(
&after.events[..before.events.len()],
before.events.as_slice()
);
assert!(
store
.update_case(
&initial.meta.id,
request(
&before,
"old-completion",
close(TicketClosureReason::Completed)
)
)
.is_err()
);
}
#[test]
fn external_case_json_rejects_six_state_authority_and_round_trips_typed_action() {
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"in_progress",
] {
assert!(serde_json::from_value::<TicketState>(serde_json::json!(state)).is_err());
}
let encoded = serde_json::json!({"action": "correct_closure", "reason": {"other": "Custom"}});
let action: TicketCaseAction = serde_json::from_value(encoded.clone()).unwrap();
assert_eq!(serde_json::to_value(action).unwrap(), encoded);
assert!(
serde_json::from_value::<TicketCaseAction>(
serde_json::json!({"action": "reopen", "reason": "completed"})
)
.is_err()
);
}
fn assignment_source() -> BTreeMap<String, String> {
BTreeMap::from([
("source_runtime_id".into(), "runtime-a".into()),
("source_worker_id".into(), "worker-a".into()),
("source_actor_role".into(), "ticket_worker".into()),
("source_assignment_id".into(), "assignment-a".into()),
])
}
fn assignment_fixture(temp: &TempDir, id: &str) -> Connection {
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
conn.execute_batch("CREATE TABLE test_worker_assignments (workspace_id TEXT, ticket_id TEXT, runtime_id TEXT, worker_id TEXT, assignment_id TEXT, status TEXT);
CREATE VIEW ticket_active_worker_assignments AS SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id FROM test_worker_assignments WHERE status = 'active';").unwrap();
conn.execute("INSERT INTO test_worker_assignments VALUES ('workspace', ?1, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![id]).unwrap();
conn
}
#[test]
fn assigned_worker_requires_exact_live_identity_but_recorded_replay_survives_release() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let conn = assignment_fixture(&temp, &initial.meta.id);
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
for key in [
"source_runtime_id",
"source_worker_id",
"source_assignment_id",
"source_actor_role",
] {
let mut wrong = assignment_source();
wrong.insert(key.into(), "mismatch".into());
assert!(
store
.clone()
.with_event_attributes(wrong)
.update_case(&initial.meta.id, operation.clone())
.is_err()
);
}
for key in [
"source_runtime_id",
"source_worker_id",
"source_assignment_id",
] {
let mut missing = assignment_source();
missing.remove(key);
assert!(
store
.clone()
.with_event_attributes(missing)
.update_case(&initial.meta.id, operation.clone())
.is_err()
);
}
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
let source = store.clone().with_event_attributes(assignment_source());
let closed = source
.update_case(&initial.meta.id, operation.clone())
.unwrap();
// The Ticket domain does not itself release or stop workers. Parent Workspace triggers own this.
assert_eq!(
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
.get::<_, String>(
0
))
.unwrap(),
"active"
);
conn.execute("UPDATE test_worker_assignments SET status = 'released'", [])
.unwrap();
let reopened = store
.update_case(
&initial.meta.id,
request(&closed, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
assert_eq!(
source.update_case(&initial.meta.id, operation).unwrap(),
reopened
);
assert!(
source
.update_case(
&initial.meta.id,
request(&reopened, "new-close", close(TicketClosureReason::Unknown))
)
.is_err()
);
assert_eq!(
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
.get::<_, String>(
0
))
.unwrap(),
"released"
);
}
#[test]
fn content_digest_changes_with_targets_or_requirements_not_case_judgment_history() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let digest = ticket_content_digest(&initial);
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::Unknown)),
)
.unwrap();
assert_eq!(ticket_content_digest(&closed), digest);
let edited = store
.edit_item(
initial.meta.id.clone().into(),
TicketItemEdit {
body: Some(MarkdownText::new("New criteria")),
..Default::default()
},
)
.unwrap();
assert_ne!(ticket_content_digest(&edited), digest);
let restored = store
.edit_item(
initial.meta.id.clone().into(),
TicketItemEdit {
body: Some(initial.document.body.clone()),
..Default::default()
},
)
.unwrap();
assert_eq!(ticket_content_digest(&restored), digest);
let targeted = store
.edit_item(
initial.meta.id.clone().into(),
TicketItemEdit {
targets: Some(TicketTargetsEdit::Set {
targets: vec![TicketTarget {
repository_key: "main".into(),
ref_selector: None,
access: TicketTargetAccess::ReadOnly,
}],
}),
..Default::default()
},
)
.unwrap();
assert_ne!(ticket_content_digest(&targeted), digest);
}
#[test]
fn external_case_request_requires_an_explicit_nullable_closure_observation() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let original = request(&initial, "close", close(TicketClosureReason::Unknown));
let mut json = serde_json::to_value(&original).unwrap();
assert_eq!(
serde_json::from_value::<TicketCaseUpdate>(json.clone()).unwrap(),
original
);
json.as_object_mut()
.unwrap()
.remove("expected_closure_operation_key");
assert!(serde_json::from_value::<TicketCaseUpdate>(json).is_err());
}
#[test]
fn conflicting_event_context_rolls_back_case_judgment_instead_of_corrupting_its_receipt() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let context = store.clone().with_event_attributes(BTreeMap::from([(
"operation_key".into(),
"wrong-key".into(),
)]));
assert!(
context
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::Unknown))
)
.is_err()
);
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
}
+134
View File
@@ -0,0 +1,134 @@
-- Frozen standalone schema7 fixture; deliberate persisted compatibility contract.
-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority.
CREATE TABLE typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_event_attributes (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_event_references (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_events (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
event_index INTEGER NOT NULL,
kind TEXT NOT NULL,
author TEXT,
at TEXT,
status TEXT,
from_state TEXT,
to_state TEXT,
reason TEXT,
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_labels (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_orchestration_plans (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
record_id TEXT NOT NULL,
kind TEXT NOT NULL,
related_ticket TEXT,
note TEXT,
accepted_summary TEXT,
accepted_branch TEXT,
accepted_worktree TEXT,
accepted_role_plan TEXT,
author TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, record_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_raw_frontmatter (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_relations (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, kind, target),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_risk_flags (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_tickets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
readiness TEXT,
workflow_state TEXT NOT NULL,
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE typed_ticket_targets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
repository_key TEXT NOT NULL,
ref_selector TEXT,
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
PRIMARY KEY (workspace_id, ticket_id, ordinal),
UNIQUE (workspace_id, ticket_id, repository_key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE "workspace_resource_key_counters" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
PRIMARY KEY (workspace_id, resource_kind)
);
CREATE TABLE "workspace_resource_keys" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
resource_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
resource_key TEXT NOT NULL,
allocated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, resource_kind, resource_id),
UNIQUE (workspace_id, resource_kind, sequence),
UNIQUE (workspace_id, resource_key)
);
CREATE INDEX idx_workspace_resource_keys_reverse
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
CREATE INDEX typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE INDEX typed_ticket_relations_workspace_source_kind
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
CREATE INDEX typed_ticket_relations_workspace_target_kind
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
CREATE INDEX typed_tickets_workspace_created
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
CREATE INDEX typed_tickets_workspace_state_updated
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
CREATE INDEX typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
+252
View File
@@ -0,0 +1,252 @@
//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots
//! migrate independently to schema9, preserving original evidence and routing handoff.
use rusqlite::{Connection, params};
use tempfile::TempDir;
use ticket::*;
fn schema7(connection: &Connection) {
connection
.execute_batch(include_str!("fixtures/schema7.sql"))
.unwrap();
connection.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 (7, 'ticket schema baseline', 'legacy');").unwrap();
}
#[test]
fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference()
{
let temp = TempDir::new().unwrap();
let path = temp.path().join("tickets.db");
let connection = Connection::open(&path).unwrap();
schema7(&connection);
connection
.execute_batch("CREATE TABLE legacy_worker_status (ticket_id TEXT, status TEXT);")
.unwrap();
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, assignee, queued_by, queued_at, resolution) VALUES ('workspace', ?1, ?1, ?1, 'open', 'task', 'P2', 'criteria', ?1, 1, 'retained-assignee', 'legacy-router', 'legacy-queue-time', 'Original decision text')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, kind, from_state, to_state, reason, author, body) VALUES ('workspace', ?1, 0, 'state_changed', 'planning', ?1, 'original reason', 'legacy-actor', 'legacy body')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'operation_key', 'legacy-receipt')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'request_fingerprint', 'legacy-fingerprint')", params![state]).unwrap();
// Actual pre-content receipt spelling: preserve it as historical input,
// never promote an old counter fingerprint into a current content CAS.
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'expected_content_digest', 'old-input')", params![state]).unwrap();
connection.execute(
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
[state],
).unwrap();
connection.execute("INSERT INTO typed_ticket_event_references VALUES ('workspace', ?1, 0, 0, 'merge_request', 'MR-existing')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_targets VALUES ('workspace', ?1, 0, 'main', 'develop', 'read_write')", params![state]).unwrap();
connection
.execute(
"INSERT INTO legacy_worker_status VALUES (?1, 'released')",
params![state],
)
.unwrap();
}
// Snapshot evidence before migration, including untyped `done` and existing MR references.
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events 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();
let old_attributes: Vec<(String, String, String)> = connection
.prepare(
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
)
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
let preserved_attributes: Vec<(String, String, String)> = connection
.prepare(
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
)
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap();
assert_eq!(old_attributes, preserved_attributes);
verify_sqlite_ticket_schema(&connection).unwrap();
let marker: i64 = connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(marker, 9);
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events 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();
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
let ticket = store.show(state.into()).unwrap();
let expected_state = if matches!(state, "done" | "closed") {
TicketState::Closed
} else {
TicketState::Open
};
assert_eq!(ticket.meta.workflow_state, expected_state);
let old_key_request = TicketCaseUpdate {
expected_content_digest: ticket_content_digest(&ticket),
expected_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: "legacy-receipt".into(),
action: if expected_state == TicketState::Closed {
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Unknown,
}
} else {
TicketCaseAction::Close {
reason: TicketClosureReason::Unknown,
}
},
explanation: "Cannot reinterpret a frozen operation receipt".into(),
references: vec![],
author: Some("human".into()),
};
assert!(matches!(
store.update_case(state, old_key_request),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(store.show(state.into()).unwrap(), ticket);
let legacy = ticket.legacy_workflow_snapshot.as_ref().unwrap();
assert_eq!(legacy.workflow_state, state);
assert!(legacy.workflow_state_explicit);
assert_eq!(legacy.status, "open");
assert_eq!(legacy.queued_by.as_deref(), Some("legacy-router"));
assert_eq!(legacy.queued_at.as_deref(), Some("legacy-queue-time"));
assert_eq!(legacy.resolution.as_deref(), Some("Original decision text"));
assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee"));
assert_eq!(ticket.meta.targets[0].repository_key, "main");
assert_eq!(ticket.events[0].from.as_deref(), Some("planning"));
assert_eq!(ticket.events[0].to.as_deref(), Some(state));
assert_eq!(ticket.events[0].references[0].target, "MR-existing");
assert_eq!(
ticket.events[0].attributes["request_fingerprint"],
"legacy-fingerprint"
);
if expected_state == TicketState::Closed {
let judgment = ticket.current_closure.as_ref().unwrap();
assert_eq!(
judgment.reason,
TicketClosureReason::Unknown,
"old done must never mean successful completion"
);
assert_eq!(judgment.explanation, "Original decision text");
let reopened = store
.update_case(
state,
TicketCaseUpdate {
expected_content_digest: ticket_content_digest(&ticket),
expected_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: "reopen-migrated".into(),
action: TicketCaseAction::Reopen,
explanation: "Reconsider the case".into(),
references: Vec::new(),
author: Some("human".into()),
},
)
.unwrap();
assert!(reopened.current_closure.is_none());
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
assert_eq!(
reopened.legacy_workflow_snapshot,
ticket.legacy_workflow_snapshot
);
} else {
assert!(ticket.current_closure.is_none());
}
}
let released: i64 = connection
.query_row(
"SELECT COUNT(*) FROM legacy_worker_status WHERE status = 'released'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(released, 6);
assert!(
connection
.execute(
"UPDATE typed_ticket_legacy_workflow_snapshot SET snapshot_json = '{}'",
[]
)
.is_err()
);
migrate_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM typed_ticket_legacy_workflow_snapshot",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
6
);
}
#[test]
fn unknown_legacy_state_rolls_back_the_independent_case_migration() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) VALUES ('workspace', 'unknown', 'unknown', 'unknown', 'open', 'task', 'P2', 'body', 'future-state', 1)", []).unwrap();
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
assert_eq!(
connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
7
);
assert_eq!(
connection
.query_row("SELECT workflow_state FROM typed_tickets", [], |row| row
.get::<_, String>(
0
))
.unwrap(),
"future-state"
);
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'typed_ticket_case_history'",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
0
);
}
#[test]
fn schema8_tag_composition_is_rejected_until_explicitly_integrated() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection
.execute("UPDATE ticket_schema_migrations SET version = 8", [])
.unwrap();
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
assert_eq!(
connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
8
);
}