backbone_support/application/service/support_write_service.rs
1//! The hand-authored support write path (user-owned; survives regen).
2//!
3//! An SLA-clock ticketing engine + warranty claims. Posts NO GL and owns no money. The load-bearing
4//! logic is the SLA clock: an Issue binds an SLA at raise time, snapshots concrete `response_by` /
5//! `resolution_by` deadlines from the matching priority, can be PAUSED (the paused span is added back to
6//! the deadlines on resume), and on resolve is judged `fulfilled` iff it beat the (pause-adjusted)
7//! resolution deadline. The one outbound seam — escalate a ticket into a real backbone-project delivery
8//! Project — runs through `ProjectPort` (zero normal Cargo edge), idempotent per issue.
9//!
10//! Tenancy: none, by design (ADR-0029). The module is tenant-agnostic — no tenant key on any
11//! write, no scope binding of its own. The COMPOSING service owns the posture: when it
12//! mounts these routes under an auth middleware that binds a row scope (e.g.
13//! `with_org_request_scope`), the database fence owns tenant isolation; a deployment that mounts
14//! them unfenced gets an unfenced module. Multi-statement transactions here relay an ambient
15//! scope onto the transaction when one is bound, and stay plain otherwise. The ONE exception is
16//! the escalation seam: the sibling port's delivery-Project row still carries the sibling's
17//! legacy owner-company key, sourced fail-closed from the ambient org scope.
18//!
19//! Clock verbs take an explicit `now: DateTime<Utc>` so the deadline math is deterministic under test.
20
21use backbone_orm::org_scope;
22use chrono::{DateTime, Duration, Utc};
23use sqlx::PgPool;
24use uuid::Uuid;
25
26use crate::infrastructure::persistence::{
27 IssueRepository, NewIssueRow, NewSlaPriorityRow, NewSlaRow, NewWarrantyClaimRow,
28 ServiceLevelAgreementRepository, ServiceLevelPriorityRepository, WarrantyClaimRepository,
29};
30
31use super::support_events::*;
32use super::support_ports::*;
33
34#[derive(Debug, thiserror::Error)]
35pub enum SupportError {
36 #[error("db: {0}")]
37 Db(#[from] sqlx::Error),
38 #[error("not found: {0}")]
39 NotFound(&'static str),
40 #[error("invalid state: {0}")]
41 InvalidState(&'static str),
42 #[error("invalid input: {0}")]
43 Invalid(String),
44 #[error("project rejected: {0}")]
45 ProjectRejected(String),
46 /// The escalation seam needs the sibling's legacy owner-company key (backbone-project is
47 /// not tenant-agnostic) and the outbox mirror is company-keyed, but no org scope carrying
48 /// a company node was bound on this request. Fails closed rather than guessing a key.
49 #[error("no company in the ambient org scope — escalation requires one (composition-installed tenancy, ADR-0029)")]
50 NoCompanyScope,
51}
52
53pub struct NewSlaPriority {
54 pub priority: String, // issue_priority variant
55 pub response_time_mins: i32,
56 pub resolution_time_mins: i32,
57}
58pub struct NewSla {
59 pub name: String,
60 pub is_default: bool,
61 pub priorities: Vec<NewSlaPriority>,
62}
63
64pub struct NewIssue {
65 pub customer_id: Option<Uuid>,
66 pub subject: String,
67 pub description: Option<String>,
68 pub priority: String, // issue_priority variant
69 pub sla_id: Option<Uuid>,
70}
71
72pub struct NewWarrantyClaim {
73 pub customer_id: Option<Uuid>,
74 pub item_id: Uuid,
75 pub serial_no: Option<String>,
76 pub warranty_expiry: Option<DateTime<Utc>>,
77 pub issue_id: Option<Uuid>,
78 pub description: Option<String>,
79}
80
81pub struct SupportWriteService {
82 pool: PgPool,
83 slas: ServiceLevelAgreementRepository,
84 sla_priorities: ServiceLevelPriorityRepository,
85 issues: IssueRepository,
86 warranty_claims: WarrantyClaimRepository,
87}
88
89impl SupportWriteService {
90 pub fn new(pool: PgPool) -> Self {
91 let slas = ServiceLevelAgreementRepository::new(pool.clone());
92 let sla_priorities = ServiceLevelPriorityRepository::new(pool.clone());
93 let issues = IssueRepository::new(pool.clone());
94 let warranty_claims = WarrantyClaimRepository::new(pool.clone());
95 Self {
96 pool,
97 slas,
98 sla_priorities,
99 issues,
100 warranty_claims,
101 }
102 }
103
104 /// Define an SLA with its per-priority first-response + resolution targets. Each target's resolution
105 /// time must be >= its response time; at least one priority row is required.
106 pub async fn create_sla(&self, s: NewSla) -> Result<Uuid, SupportError> {
107 if s.name.trim().is_empty() {
108 return Err(SupportError::Invalid("SLA needs a name".into()));
109 }
110 if s.priorities.is_empty() {
111 return Err(SupportError::Invalid(
112 "an SLA needs at least one priority target".into(),
113 ));
114 }
115 for p in &s.priorities {
116 if p.response_time_mins < 0 || p.resolution_time_mins < 0 {
117 return Err(SupportError::Invalid(
118 "SLA times must be non-negative".into(),
119 ));
120 }
121 if p.resolution_time_mins < p.response_time_mins {
122 return Err(SupportError::Invalid(
123 "resolution target must be >= response target".into(),
124 ));
125 }
126 }
127 let id = Uuid::new_v4();
128 let mut tx = self.pool.begin().await?;
129 // Propagate the ambient request scope, when one is bound, onto this transaction: the
130 // repositories' scoped helpers ride the request-dedicated connection, but this pool
131 // transaction does not, and rows a deployment's fence decorates are invisible to an
132 // unscoped connection. Binding the ambient scope relay-only keeps the module
133 // posture-agnostic — unfenced deployments have no ambient scope and skip this entirely.
134 if let Some(scope) = org_scope::current_org_scope() {
135 org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
136 }
137 self.slas
138 .insert_sla(
139 &mut tx,
140 &NewSlaRow {
141 id,
142 name: &s.name,
143 is_default: s.is_default,
144 },
145 )
146 .await?;
147 for p in &s.priorities {
148 self.sla_priorities
149 .insert_priority(
150 &mut tx,
151 &NewSlaPriorityRow {
152 id: Uuid::new_v4(),
153 sla_id: id,
154 priority: &p.priority,
155 response_time_mins: p.response_time_mins,
156 resolution_time_mins: p.resolution_time_mins,
157 },
158 )
159 .await?;
160 }
161 tx.commit().await?;
162 Ok(id)
163 }
164
165 /// Raise a ticket. It binds an SLA (the given one, else the default) and snapshots concrete
166 /// `response_by` / `resolution_by` deadlines from the matching priority target. With no SLA the clock
167 /// is untracked (deadlines NULL).
168 pub async fn raise_issue(&self, i: NewIssue, now: DateTime<Utc>) -> Result<Uuid, SupportError> {
169 if i.subject.trim().is_empty() {
170 return Err(SupportError::Invalid("issue needs a subject".into()));
171 }
172 // Resolve the SLA: explicit, else the default. Under a composing service's row fence
173 // (RLS), the scope bound on the request connection limits what the default lookup sees;
174 // with no fence mounted, this is a plain lookup.
175 let sla_id: Option<Uuid> = match i.sla_id {
176 Some(id) => Some(id),
177 None => self.slas.find_default_id(&self.pool).await?,
178 };
179 // Snapshot deadlines from the matching priority target (if an SLA is bound).
180 let (mut response_by, mut resolution_by) = (None, None);
181 if let Some(sid) = sla_id {
182 let target = self
183 .sla_priorities
184 .find_target(&self.pool, sid, &i.priority)
185 .await?
186 .ok_or(SupportError::Invalid(
187 "SLA has no target for this priority".into(),
188 ))?;
189 response_by = Some(now + Duration::minutes(target.response_time_mins as i64));
190 resolution_by = Some(now + Duration::minutes(target.resolution_time_mins as i64));
191 }
192 let id = Uuid::new_v4();
193 self.issues
194 .insert_issue(
195 &self.pool,
196 &NewIssueRow {
197 id,
198 customer_id: i.customer_id,
199 subject: &i.subject,
200 description: i.description.as_deref(),
201 priority: &i.priority,
202 sla_id,
203 opened_at: now,
204 response_by,
205 resolution_by,
206 },
207 )
208 .await?;
209 Ok(id)
210 }
211
212 /// Record the first response — moves the clock from first-response to resolution tracking, and
213 /// **judges the response leg**: a response after `response_by` breaches the SLA. The breach is
214 /// persisted (`response_breached`) and immediately flips agreement_status to `failed`, because a
215 /// missed first response fails the SLA even if the resolution later lands on time (a met resolution
216 /// no longer masks a blown response — completeness council 2026-07-07). Judged inside the gated
217 /// UPDATE against the row's live `response_by` (pause-adjusted), never a stale read.
218 pub async fn record_first_response(
219 &self,
220 issue_id: Uuid,
221 now: DateTime<Utc>,
222 ) -> Result<(), SupportError> {
223 let moved = self
224 .issues
225 .record_first_response(&self.pool, issue_id, now)
226 .await?;
227 if moved != 1 {
228 return Err(SupportError::InvalidState(
229 "issue is not awaiting a first response",
230 ));
231 }
232 Ok(())
233 }
234
235 /// Pause the SLA clock (ticket on hold — waiting on customer / third party).
236 pub async fn pause_sla(&self, issue_id: Uuid, now: DateTime<Utc>) -> Result<(), SupportError> {
237 let moved = self.issues.pause(&self.pool, issue_id, now).await?;
238 if moved != 1 {
239 return Err(SupportError::InvalidState(
240 "only an open/replied issue can be paused",
241 ));
242 }
243 Ok(())
244 }
245
246 /// Resume the SLA clock — add the paused span back to the outstanding deadlines so a hold never
247 /// counts against the SLA. Restores the running status (open if not yet responded, else replied).
248 pub async fn resume_sla(&self, issue_id: Uuid, now: DateTime<Utc>) -> Result<(), SupportError> {
249 let mut tx = self.pool.begin().await?;
250 // The locking read + the deadline extension are one transaction on the pool, not a
251 // request-connection statement — relay the ambient request scope onto it, when one is
252 // bound, so a decorated deployment's fence sees the row.
253 if let Some(scope) = org_scope::current_org_scope() {
254 org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
255 }
256 let row = self.issues.lock_on_hold(&mut tx, issue_id).await?;
257 let row = match row {
258 Some(r) => r,
259 None => {
260 tx.rollback().await?;
261 return Err(SupportError::InvalidState("issue is not on hold"));
262 }
263 };
264 let paused_mins = (now - row.paused_at).num_minutes().max(0);
265 let running = if row.first_responded_at.is_some() {
266 "replied"
267 } else {
268 "open"
269 };
270 // Extend the resolution deadline by the paused span; extend the response deadline too while the
271 // first response is still outstanding.
272 self.issues
273 .apply_resume(&mut tx, issue_id, paused_mins as i32, running)
274 .await?;
275 tx.commit().await?;
276 Ok(())
277 }
278
279 /// Resolve a ticket. Judged `fulfilled` iff resolved at/before the (pause-adjusted) resolution
280 /// deadline — a ticket with no SLA is always fulfilled (no promise to breach). Emits `IssueResolved`.
281 pub async fn resolve_issue(
282 &self,
283 issue_id: Uuid,
284 now: DateTime<Utc>,
285 sink: &dyn SupportEventSink,
286 ) -> Result<bool, SupportError> {
287 let issue = self
288 .issues
289 .find_state(&self.pool, issue_id)
290 .await?
291 .ok_or(SupportError::NotFound("issue"))?;
292 if issue.status == "on_hold" {
293 return Err(SupportError::InvalidState(
294 "resume the issue before resolving",
295 ));
296 }
297 if issue.status != "open" && issue.status != "replied" {
298 return Err(SupportError::InvalidState(
299 "only an open/replied issue can be resolved",
300 ));
301 }
302 // Compute the verdict INSIDE the gated UPDATE from the row's LIVE resolution_by — not from the
303 // snapshot read above (which is only for the friendly on_hold/not-found errors). A concurrent
304 // pause+resume that commits between that read and here extends resolution_by and restores an
305 // open/replied status, so a read-then-write split would stamp a stale verdict (a met SLA marked
306 // `failed`). Judging in the same statement as the transition closes that race (maturity council
307 // 2026-07-07). A ticket with no SLA (NULL resolution_by) is always fulfilled.
308 // fulfilled requires BOTH legs met: the resolution is within resolution_by, AND the first
309 // response leg is not breached — either an already-recorded on-time response, or (no response
310 // recorded yet) resolving within response_by counts as responding in time. A ticket that blew
311 // its response deadline — recorded late, or still unanswered past response_by — fails even if
312 // the resolution lands on time (completeness council 2026-07-07).
313 let fulfilled = self
314 .issues
315 .resolve(&self.pool, issue_id, now)
316 .await?
317 .ok_or(SupportError::InvalidState("issue is no longer resolvable"))?;
318 sink.publish(&SupportEvent::IssueResolved(IssueResolved {
319 issue_id,
320 fulfilled,
321 }));
322 Ok(fulfilled)
323 }
324
325 /// Close a resolved ticket (terminal).
326 pub async fn close_issue(&self, issue_id: Uuid) -> Result<(), SupportError> {
327 let moved = self.issues.close(&self.pool, issue_id).await?;
328 if moved != 1 {
329 return Err(SupportError::InvalidState(
330 "only a resolved issue can be closed",
331 ));
332 }
333 Ok(())
334 }
335
336 /// Escalate a ticket into a real backbone-project delivery Project (drives `ProjectPort`, idempotent
337 /// per issue). Transition-gates on `escalated_project_id IS NULL` so a ticket escalates **once**.
338 /// Emits `IssueEscalated`.
339 pub async fn escalate_to_project(
340 &self,
341 issue_id: Uuid,
342 project: &dyn ProjectPort,
343 sink: &dyn SupportEventSink,
344 ) -> Result<Uuid, SupportError> {
345 let issue = self
346 .issues
347 .find_escalation_candidate(&self.pool, issue_id)
348 .await?
349 .ok_or(SupportError::NotFound("issue"))?;
350 if let Some(pid) = issue.escalated_project_id {
351 return Ok(pid); // already escalated
352 }
353 if issue.status == "resolved" || issue.status == "closed" {
354 return Err(SupportError::InvalidState(
355 "a resolved/closed issue cannot be escalated",
356 ));
357 }
358 let customer_id: Uuid = issue.customer_id.ok_or(SupportError::Invalid(
359 "issue has no customer to open a project for".into(),
360 ))?;
361 // The delivery-project seam is a cross-module port whose sibling (backbone-project) is
362 // not tenant-agnostic: its Project rows still carry the legacy owner-company key. That
363 // key is the SIBLING's domain parameter (composition-installed tenancy, ADR-0029), not
364 // a fence of this module's — source it from the ambient org scope and fail closed when
365 // the request carries no company-anchored scope. The same key feeds the company-keyed
366 // outbox mirror row staged below.
367 let company_id = org_scope::current_org_scope()
368 .and_then(|s| s.legacy_company_id())
369 .ok_or(SupportError::NoCompanyScope)?;
370
371 // Open the delivery project (idempotent per issue on the project side).
372 let ack = project
373 .open_delivery_project(&ProjectFromIssue {
374 company_id,
375 issue_id,
376 customer_id,
377 subject: issue.subject,
378 })
379 .await
380 .map_err(|r| SupportError::ProjectRejected(r.code))?;
381
382 // Gate: claim the escalation exactly once, and stage the event in the SAME tx (outbox rollout plan,
383 // P2): backbone-project subscribes to IssueEscalated to open the delivery project, so a crash between
384 // the CAS and the in-proc publish must not drop it.
385 let mut tx = self.pool.begin().await?;
386 // Relay the ambient request scope onto this transaction so the CAS and the outbox stage
387 // run fenced under a decorated deployment, regardless of who drives the escalation.
388 if let Some(scope) = org_scope::current_org_scope() {
389 org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
390 }
391 let moved = self
392 .issues
393 .claim_escalation(&mut tx, issue_id, ack.project_id)
394 .await?;
395 if moved != 1 {
396 tx.rollback().await?;
397 let pid = self
398 .issues
399 .fetch_escalated_project_id(&self.pool, issue_id)
400 .await?;
401 return Ok(pid);
402 }
403 let event = SupportEvent::IssueEscalated(IssueEscalated {
404 issue_id,
405 project_id: ack.project_id,
406 });
407 let record = backbone_outbox::OutboxRecord::new(
408 "IssueEscalated",
409 "Issue",
410 issue_id.to_string(),
411 company_id,
412 serde_json::to_value(&event).map_err(|e| SupportError::Invalid(e.to_string()))?,
413 chrono::Utc::now(),
414 );
415 backbone_outbox::outbox::stage(&mut *tx, "support", &record)
416 .await
417 .map_err(|e| SupportError::Invalid(format!("outbox stage: {e}")))?;
418 tx.commit().await?;
419 sink.publish(&event);
420 Ok(ack.project_id)
421 }
422
423 /// File a warranty claim. Coverage is computed at file time: `is_under_warranty = claim_date <=
424 /// warranty_expiry` (an unknown/absent expiry is out of warranty). Emits `WarrantyClaimFiled`.
425 pub async fn file_warranty_claim(
426 &self,
427 c: NewWarrantyClaim,
428 now: DateTime<Utc>,
429 sink: &dyn SupportEventSink,
430 ) -> Result<Uuid, SupportError> {
431 let under = c.warranty_expiry.map(|e| now <= e).unwrap_or(false);
432 let id = Uuid::new_v4();
433 let claim = NewWarrantyClaimRow {
434 id,
435 customer_id: c.customer_id,
436 item_id: c.item_id,
437 serial_no: c.serial_no.as_deref(),
438 claim_date: now,
439 warranty_expiry: c.warranty_expiry,
440 is_under_warranty: under,
441 issue_id: c.issue_id,
442 description: c.description.as_deref(),
443 };
444 self.warranty_claims
445 .insert_claim(&self.pool, &claim)
446 .await?;
447 sink.publish(&SupportEvent::WarrantyClaimFiled(WarrantyClaimFiled {
448 claim_id: id,
449 is_under_warranty: under,
450 }));
451 Ok(id)
452 }
453
454 /// Adjudicate an open warranty claim (accept or reject) with a resolution note.
455 pub async fn resolve_warranty_claim(
456 &self,
457 claim_id: Uuid,
458 accepted: bool,
459 resolution: Option<String>,
460 ) -> Result<(), SupportError> {
461 let status = if accepted { "accepted" } else { "rejected" };
462 let moved = self
463 .warranty_claims
464 .adjudicate(&self.pool, claim_id, status, resolution.as_deref())
465 .await?;
466 if moved != 1 {
467 return Err(SupportError::InvalidState(
468 "only an open claim can be adjudicated",
469 ));
470 }
471 Ok(())
472 }
473}